mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
changes
This commit is contained in:
@@ -48,6 +48,7 @@ import {
|
|||||||
EDR_FREIGHT_PERMISSIONS,
|
EDR_FREIGHT_PERMISSIONS,
|
||||||
} from "./seed/edr-freight.seed";
|
} from "./seed/edr-freight.seed";
|
||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||||
|
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||||
import { PaymentModule } from "./modules/payment/payment.module";
|
import { PaymentModule } from "./modules/payment/payment.module";
|
||||||
@@ -157,6 +158,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
FreightPositionsSeeder,
|
||||||
DemoUsersSeeder,
|
DemoUsersSeeder,
|
||||||
FreightStaffUsersSeeder,
|
FreightStaffUsersSeeder,
|
||||||
PricingDataSeeder,
|
PricingDataSeeder,
|
||||||
@@ -180,6 +182,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly seeder: DataSeeder,
|
private readonly seeder: DataSeeder,
|
||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
|
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||||
@@ -201,6 +204,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.freightPermissionKeyMigrationSeeder.run();
|
await this.freightPermissionKeyMigrationSeeder.run();
|
||||||
await this.seeder.run();
|
await this.seeder.run();
|
||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
|
await this.freightPositionsSeeder.run();
|
||||||
await this.demoUsersSeeder.run();
|
await this.demoUsersSeeder.run();
|
||||||
await this.freightStaffUsersSeeder.run();
|
await this.freightStaffUsersSeeder.run();
|
||||||
await this.pricingDataSeeder.run();
|
await this.pricingDataSeeder.run();
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segment corridor bookings: a booking may ride only part of a train's route
|
||||||
|
* (its own origin→destination leg), so dispatch/arrival become per-booking
|
||||||
|
* facts and wagon capacity is consumed per leg instead of per whole route.
|
||||||
|
*
|
||||||
|
* - bookings.loaded_at / arrived_at (+ by-user): operator-confirmed load at
|
||||||
|
* the booking's origin yard and unload at its destination yard. Clearance
|
||||||
|
* gates read arrived_at, not the train's actual_arrival_at.
|
||||||
|
* - train_set_wagons.board_yard_id / alight_yard_id: the leg a consist slot
|
||||||
|
* occupies; NULL/NULL = whole route (legacy). Non-overlapping legs coexist
|
||||||
|
* without consuming each other's capacity.
|
||||||
|
* - wagon_movements: auditable ledger of every physical wagon relocation
|
||||||
|
* (loaded leg / empty reposition / manual correction) with the acting user.
|
||||||
|
*/
|
||||||
|
export class SegmentCorridorBookings1990000000000 implements MigrationInterface {
|
||||||
|
name = 'SegmentCorridorBookings1990000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS loaded_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid,
|
||||||
|
ADD COLUMN IF NOT EXISTS arrived_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS arrived_by_user_id uuid;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_set_wagons
|
||||||
|
ADD COLUMN IF NOT EXISTS board_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS alight_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.wagon_movements (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
wagon_id uuid NOT NULL REFERENCES freight.wagons(id) ON DELETE CASCADE,
|
||||||
|
from_yard_id uuid REFERENCES freight.yards(id),
|
||||||
|
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
|
||||||
|
train_schedule_id uuid REFERENCES freight.train_schedules(id) ON DELETE SET NULL,
|
||||||
|
booking_id uuid REFERENCES freight.bookings(id) ON DELETE SET NULL,
|
||||||
|
kind varchar(30) NOT NULL,
|
||||||
|
moved_by_user_id uuid,
|
||||||
|
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
note text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_wagon_occurred" ON freight.wagon_movements (wagon_id, occurred_at);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_schedule" ON freight.wagon_movements (train_schedule_id);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_movements;`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_set_wagons
|
||||||
|
DROP COLUMN IF EXISTS board_yard_id,
|
||||||
|
DROP COLUMN IF EXISTS alight_yard_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS loaded_at,
|
||||||
|
DROP COLUMN IF EXISTS loaded_by_user_id,
|
||||||
|
DROP COLUMN IF EXISTS arrived_at,
|
||||||
|
DROP COLUMN IF EXISTS arrived_by_user_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
|||||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||||
{
|
{
|
||||||
key: 'operations',
|
key: 'operations',
|
||||||
statuses: ['IN_TRANSIT', 'PAID'],
|
statuses: ['IN_TRANSIT', 'ARRIVED', 'PAID'],
|
||||||
},
|
},
|
||||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export function computeNextStep(
|
|||||||
description: 'Mark shipment as in transit',
|
description: 'Mark shipment as in transit',
|
||||||
};
|
};
|
||||||
case 'IN_TRANSIT':
|
case 'IN_TRANSIT':
|
||||||
|
case 'ARRIVED':
|
||||||
return {
|
return {
|
||||||
action: 'COMPLETE',
|
action: 'COMPLETE',
|
||||||
description: 'Mark shipment complete',
|
description: 'Mark shipment complete',
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
async complete(bookingId: string): Promise<Booking> {
|
async complete(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["IN_TRANSIT"]);
|
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
|
|||||||
@@ -1019,6 +1019,44 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corridor day pool: ready, not-yet-allocated bookings for one EAT day whose
|
||||||
|
* origin AND destination both lie on the day's corridor stop set — covers
|
||||||
|
* full-route bookings and sub-corridor bookings (Dire→Djibouti on an
|
||||||
|
* Addis→…→Djibouti train). The caller still verifies stop ORDER per train
|
||||||
|
* via the corridor budget; this query only narrows the pool. Same status
|
||||||
|
* rules and ordering as {@link findBatchPool}.
|
||||||
|
*/
|
||||||
|
findBatchPoolByCorridorDay(
|
||||||
|
corridorYardIds: string[],
|
||||||
|
day: string,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
if (corridorYardIds.length === 0) return Promise.resolve([]);
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
|
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||||
|
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||||
|
corridorYardIds,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||||
|
{ day },
|
||||||
|
)
|
||||||
|
.andWhere('sb.id IS NULL')
|
||||||
|
.andWhere(
|
||||||
|
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||||
|
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||||
|
)
|
||||||
|
.orderBy('booking.is_government', 'DESC')
|
||||||
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
|
.addOrderBy('booking.created_at', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||||
return this.repository
|
return this.repository
|
||||||
|
|||||||
@@ -605,10 +605,15 @@ export class BookingsService {
|
|||||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||||
throw new BadRequestException('Selected schedule is no longer accepting bookings');
|
throw new BadRequestException('Selected schedule is no longer accepting bookings');
|
||||||
}
|
}
|
||||||
if (
|
// Corridor-aware: the booking's leg must lie on the schedule's route in
|
||||||
schedule.originStationId !== dto.originYardId ||
|
// stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti
|
||||||
schedule.destinationStationId !== dto.destinationYardId
|
// train) are valid.
|
||||||
) {
|
const stops = await this.trainSchedulingService.stopYardsForSchedule(
|
||||||
|
schedule,
|
||||||
|
);
|
||||||
|
const fromIdx = stops.indexOf(dto.originYardId);
|
||||||
|
const toIdx = stops.indexOf(dto.destinationYardId);
|
||||||
|
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
|
||||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||||
}
|
}
|
||||||
} else if (dto.scheduledDate) {
|
} else if (dto.scheduledDate) {
|
||||||
@@ -1278,6 +1283,14 @@ export class BookingsService {
|
|||||||
): Promise<Freight.IBookingTracking> {
|
): Promise<Freight.IBookingTracking> {
|
||||||
const booking = await this.findById(bookingId);
|
const booking = await this.findById(bookingId);
|
||||||
|
|
||||||
|
const journey = {
|
||||||
|
bookingStatus: booking.status ?? null,
|
||||||
|
bookingOriginYardId: booking.originYardId ?? null,
|
||||||
|
bookingDestinationYardId: booking.destinationYardId ?? null,
|
||||||
|
loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null,
|
||||||
|
arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null,
|
||||||
|
};
|
||||||
|
|
||||||
const empty: Freight.IBookingTracking = {
|
const empty: Freight.IBookingTracking = {
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
bookingReference: booking.reference,
|
bookingReference: booking.reference,
|
||||||
@@ -1295,6 +1308,7 @@ export class BookingsService {
|
|||||||
actualArrivalAt: null,
|
actualArrivalAt: null,
|
||||||
scheduledDepartureAt: null,
|
scheduledDepartureAt: null,
|
||||||
scheduledArrivalAt: null,
|
scheduledArrivalAt: null,
|
||||||
|
...journey,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!booking.trainScheduleId) {
|
if (!booking.trainScheduleId) {
|
||||||
@@ -1331,6 +1345,7 @@ export class BookingsService {
|
|||||||
actualArrivalAt: track.actualArrivalAt,
|
actualArrivalAt: track.actualArrivalAt,
|
||||||
scheduledDepartureAt: track.scheduledDepartureAt,
|
scheduledDepartureAt: track.scheduledDepartureAt,
|
||||||
scheduledArrivalAt: track.scheduledArrivalAt,
|
scheduledArrivalAt: track.scheduledArrivalAt,
|
||||||
|
...journey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,7 +1622,7 @@ export class BookingsService {
|
|||||||
if (!booking.isGovernment) {
|
if (!booking.isGovernment) {
|
||||||
throw new BadRequestException('Only government bookings can be expedited');
|
throw new BadRequestException('Only government bookings can be expedited');
|
||||||
}
|
}
|
||||||
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
||||||
if (blocked.includes(booking.status)) {
|
if (blocked.includes(booking.status)) {
|
||||||
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const BOOKING_STATUSES = [
|
|||||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
'PAID',
|
'PAID',
|
||||||
'IN_TRANSIT',
|
'IN_TRANSIT',
|
||||||
|
'ARRIVED',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
'REJECTED',
|
'REJECTED',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
@@ -458,6 +459,24 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
trainScheduleId?: string | null;
|
trainScheduleId?: string | null;
|
||||||
|
|
||||||
|
// ── Per-booking journey (segment corridor bookings) ────────────────────────
|
||||||
|
// A booking rides only its own origin→destination leg of the train's route,
|
||||||
|
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates
|
||||||
|
// read arrivedAt (booking arrival), never the schedule's actualArrivalAt.
|
||||||
|
/** Operator confirmed cargo loaded at the booking's origin yard (per-booking dispatch). */
|
||||||
|
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||||
|
loadedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
loadedByUserId?: string | null;
|
||||||
|
|
||||||
|
/** Operator confirmed cargo unloaded at the booking's destination yard (per-booking arrival). */
|
||||||
|
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||||
|
arrivedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'arrived_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
arrivedByUserId?: string | null;
|
||||||
|
|
||||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||||
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
||||||
paymentDeadline?: Date | null;
|
paymentDeadline?: Date | null;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const COMMITTED_STATUSES = [
|
|||||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
'PAID',
|
'PAID',
|
||||||
'IN_TRANSIT',
|
'IN_TRANSIT',
|
||||||
|
'ARRIVED',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
'DELIVERED',
|
'DELIVERED',
|
||||||
'CONSOLIDATED',
|
'CONSOLIDATED',
|
||||||
|
|||||||
@@ -202,15 +202,22 @@ export class GlOperationsService {
|
|||||||
.findOne({ where: { id: booking.trainScheduleId } });
|
.findOne({ where: { id: booking.trainScheduleId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-booking journey first: a booking rides only its own leg, so ITS
|
||||||
|
// loaded/arrived timestamps gate clearance — a Dire→Djibouti booking that
|
||||||
|
// unloaded at its own destination clears while the train keeps rolling,
|
||||||
|
// and a booking still on board does NOT clear just because the train
|
||||||
|
// arrived. The schedule actuals remain only as fallback for legacy
|
||||||
|
// in-flight bookings that predate per-booking load/unload (no loadedAt).
|
||||||
|
const departedAt = booking.loadedAt ?? schedule?.actualDepartureAt ?? null;
|
||||||
|
const arrivedAt =
|
||||||
|
booking.arrivedAt ??
|
||||||
|
(booking.loadedAt ? null : (schedule?.actualArrivalAt ?? null));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduleId: schedule?.id ?? null,
|
scheduleId: schedule?.id ?? null,
|
||||||
wagonAllocated,
|
wagonAllocated,
|
||||||
departedAt: schedule?.actualDepartureAt
|
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
|
||||||
? new Date(schedule.actualDepartureAt).toISOString()
|
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
|
||||||
: null,
|
|
||||||
arrivedAt: schedule?.actualArrivalAt
|
|
||||||
? new Date(schedule.actualArrivalAt).toISOString()
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +285,7 @@ export class GlOperationsService {
|
|||||||
/**
|
/**
|
||||||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||||||
* is secured on the train schedule (which itself follows wagon allocation).
|
* is secured on the train schedule (which itself follows wagon allocation).
|
||||||
* Replaces the previous batch; locked once the train departs or T1 is closed.
|
* Replaces the previous batch; locked only once GL Ethiopia closes the T1.
|
||||||
*/
|
*/
|
||||||
async uploadT1Documents(
|
async uploadT1Documents(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
@@ -304,11 +311,8 @@ export class GlOperationsService {
|
|||||||
if (state.closed) {
|
if (state.closed) {
|
||||||
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
||||||
}
|
}
|
||||||
if (state.trainDepartedAt) {
|
// Departure no longer locks T1 docs — GL DJ may replace them any time until
|
||||||
throw new BadRequestException(
|
// GL Ethiopia closes/accepts the T1.
|
||||||
'The train has departed — T1 transport documents can no longer be changed.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistT1TransportUploads(this.filesService, bookingId, files);
|
await persistT1TransportUploads(this.filesService, bookingId, files);
|
||||||
return { uploaded: files.length };
|
return { uploaded: files.length };
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
|||||||
'OPERATION_CHANGES_REQUESTED',
|
'OPERATION_CHANGES_REQUESTED',
|
||||||
'ROAD_DISPATCH_PENDING',
|
'ROAD_DISPATCH_PENDING',
|
||||||
'IN_TRANSIT',
|
'IN_TRANSIT',
|
||||||
|
'ARRIVED',
|
||||||
'PAID',
|
'PAID',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
findPaidUnlinkedForSchedule: jest.Mock;
|
findPaidUnlinkedForSchedule: jest.Mock;
|
||||||
findBatchPool: jest.Mock;
|
findBatchPool: jest.Mock;
|
||||||
findBatchPoolByRouteDay: jest.Mock;
|
findBatchPoolByRouteDay: jest.Mock;
|
||||||
|
findBatchPoolByCorridorDay: jest.Mock;
|
||||||
findReservedForSchedule: jest.Mock;
|
findReservedForSchedule: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
@@ -53,6 +54,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||||
|
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
|
||||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
@@ -196,6 +198,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
cargoTotalWeightVgm: 10,
|
cargoTotalWeightVgm: 10,
|
||||||
freightType: 'CONTAINER',
|
freightType: 'CONTAINER',
|
||||||
bookingContainers: [],
|
bookingContainers: [],
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
}) as unknown as Booking;
|
}) as unknown as Booking;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -227,13 +231,15 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
trainSetId: `set-${id}`,
|
trainSetId: `set-${id}`,
|
||||||
trainSet: { locomotive: smallLoco },
|
trainSet: { locomotive: smallLoco },
|
||||||
scheduleBookings: [],
|
scheduleBookings: [],
|
||||||
|
originStationId: originYardId,
|
||||||
|
destinationStationId: destinationYardId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||||||
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||||
commercial('hi', 30),
|
commercial('hi', 30),
|
||||||
commercial('mid', 20),
|
commercial('mid', 20),
|
||||||
commercial('lo', 10),
|
commercial('lo', 10),
|
||||||
@@ -241,9 +247,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
|
|
||||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith(
|
||||||
originYardId,
|
[originYardId, destinationYardId],
|
||||||
destinationYardId,
|
|
||||||
day,
|
day,
|
||||||
);
|
);
|
||||||
// Both trains were processed.
|
// Both trains were processed.
|
||||||
@@ -258,7 +263,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reserves the chosen train id on each commercial booking', async () => {
|
it('reserves the chosen train id on each commercial booking', async () => {
|
||||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([commercial('hi', 30)]);
|
||||||
|
|
||||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
@@ -286,9 +291,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
freightType: 'CONTAINER',
|
freightType: 'CONTAINER',
|
||||||
consolidationPartnerId: partnerId,
|
consolidationPartnerId: partnerId,
|
||||||
bookingContainers: [{ quantity: 1 }],
|
bookingContainers: [{ quantity: 1 }],
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
}) as unknown as Booking;
|
}) as unknown as Booking;
|
||||||
|
|
||||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||||
consol('a', 'b', 30),
|
consol('a', 'b', 30),
|
||||||
consol('b', 'a', 20),
|
consol('b', 'a', 20),
|
||||||
]);
|
]);
|
||||||
@@ -313,9 +320,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
freightType: 'CONTAINER',
|
freightType: 'CONTAINER',
|
||||||
consolidationPartnerId: 'missing-partner',
|
consolidationPartnerId: 'missing-partner',
|
||||||
bookingContainers: [{ quantity: 1 }],
|
bookingContainers: [{ quantity: 1 }],
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
} as unknown as Booking;
|
} as unknown as Booking;
|
||||||
|
|
||||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]);
|
||||||
|
|
||||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||||
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
@@ -42,13 +43,14 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
|||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||||
|
import {
|
||||||
|
Capacity,
|
||||||
|
CorridorBudget,
|
||||||
|
CorridorLeg,
|
||||||
|
stopYardsFor,
|
||||||
|
} from './corridor-capacity.util';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
export type { Capacity } from './corridor-capacity.util';
|
||||||
export interface Capacity {
|
|
||||||
wagons: number;
|
|
||||||
weightTons: number;
|
|
||||||
lengthMeters: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||||
interface RouteDayGroup {
|
interface RouteDayGroup {
|
||||||
@@ -459,18 +461,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
throw new BadRequestException('Booking has no scheduled date');
|
throw new BadRequestException('Booking has no scheduled date');
|
||||||
}
|
}
|
||||||
const day = eatDay(new Date(booking.scheduledDate));
|
const day = eatDay(new Date(booking.scheduledDate));
|
||||||
|
// Corridor-aware: any train whose route carries the booking's origin
|
||||||
|
// strictly before its destination qualifies — a Dire→Djibouti booking may
|
||||||
|
// ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the
|
||||||
|
// stop order, so we fetch the day's open trains without endpoint filters.
|
||||||
const corridor = await this.trainSchedulesRepository.findAll({
|
const corridor = await this.trainSchedulesRepository.findAll({
|
||||||
where: [
|
where: [
|
||||||
{
|
{ status: TrainScheduleStatusEnum.Draft },
|
||||||
originStationId: booking.originYardId,
|
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||||
destinationStationId: booking.destinationYardId,
|
|
||||||
status: TrainScheduleStatusEnum.Draft,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
originStationId: booking.originYardId,
|
|
||||||
destinationStationId: booking.destinationYardId,
|
|
||||||
status: TrainScheduleStatusEnum.Scheduled,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const candidates = corridor
|
const candidates = corridor
|
||||||
@@ -493,6 +491,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const rules = await this.loadGlobalRules();
|
const rules = await this.loadGlobalRules();
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
const required = need ?? this.needFor(booking, wagonLengths);
|
const required = need ?? this.needFor(booking, wagonLengths);
|
||||||
|
let corridorMatched = false;
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
candidate.id,
|
candidate.id,
|
||||||
@@ -500,8 +499,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||||
if (this.fits(required, budget)) return schedule.id;
|
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
|
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||||
|
corridorMatched = true;
|
||||||
|
if (budget.fits(required, leg)) return schedule.id;
|
||||||
|
}
|
||||||
|
if (!corridorMatched) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'No export train is accepting bookings for this day',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||||
}
|
}
|
||||||
@@ -1009,8 +1016,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||||
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||||
if (budget.wagons <= 0) {
|
if (budget.maxRemaining().wagons <= 0) {
|
||||||
await this.setWindow(scheduleId, "FULL");
|
await this.setWindow(scheduleId, "FULL");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1026,16 +1033,20 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
? this.combinedNeed(booking, partner, wagonLengths)
|
? this.combinedNeed(booking, partner, wagonLengths)
|
||||||
: this.needFor(booking, wagonLengths);
|
: this.needFor(booking, wagonLengths);
|
||||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||||
|
// Consolidated partners always share one corridor, so the primary's leg
|
||||||
|
// stands for the pair.
|
||||||
|
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||||
|
|
||||||
if (!this.fits(need, budget)) {
|
if (!budget.fits(need, leg)) {
|
||||||
if (isGov) {
|
if (isGov) {
|
||||||
budget = await this.preemptForGovernment(
|
const freed = await this.preemptForGovernment(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
need,
|
need,
|
||||||
|
leg,
|
||||||
budget,
|
budget,
|
||||||
wagonLengths,
|
wagonLengths,
|
||||||
);
|
);
|
||||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
if (!freed) continue; // still doesn't fit even after preempt
|
||||||
} else {
|
} else {
|
||||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||||
}
|
}
|
||||||
@@ -1049,11 +1060,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (partner) await this.reserve(partner, scheduleId);
|
if (partner) await this.reserve(partner, scheduleId);
|
||||||
armed = true;
|
armed = true;
|
||||||
}
|
}
|
||||||
budget = this.subtract(budget, need);
|
budget.subtract(need, leg);
|
||||||
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
|
if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board
|
||||||
}
|
}
|
||||||
|
|
||||||
if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||||
if (armed) this.armSettle(scheduleId);
|
if (armed) this.armSettle(scheduleId);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
@@ -1106,8 +1117,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const rules = await this.loadGlobalRules();
|
const rules = await this.loadGlobalRules();
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
|
||||||
// Live per-schedule budget + arm flag, in departure order.
|
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
||||||
for (const id of scheduleIds) {
|
for (const id of scheduleIds) {
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||||
@@ -1120,18 +1131,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||||
const budget = await this.remainingCapacity(
|
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||||
schedule,
|
|
||||||
limits,
|
|
||||||
wagonLengths,
|
|
||||||
);
|
|
||||||
trains.push({ id, budget, armed: false });
|
trains.push({ id, budget, armed: false });
|
||||||
}
|
}
|
||||||
if (trains.length === 0) return [];
|
if (trains.length === 0) return [];
|
||||||
|
|
||||||
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
// The day pool covers every booking whose leg lies somewhere on one of the
|
||||||
originYardId,
|
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
|
||||||
destinationYardId,
|
// Addis→Djibouti train). Which train actually takes a booking is decided
|
||||||
|
// by the per-train legOf check below.
|
||||||
|
const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))];
|
||||||
|
const pool = await this.bookingsRepository.findBatchPoolByCorridorDay(
|
||||||
|
corridorYards,
|
||||||
day,
|
day,
|
||||||
);
|
);
|
||||||
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||||
@@ -1146,20 +1157,30 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
: this.needFor(booking, wagonLengths);
|
: this.needFor(booking, wagonLengths);
|
||||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||||
|
|
||||||
// First train (earliest departure) that fits this unit as-is.
|
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
||||||
let target = trains.find((t) => this.fits(need, t.budget));
|
t.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
|
|
||||||
|
// First train (earliest departure) whose corridor carries this booking's
|
||||||
|
// leg and still fits it as-is.
|
||||||
|
let target = trains.find((t) => {
|
||||||
|
const leg = legOn(t);
|
||||||
|
return leg != null && t.budget.fits(need, leg);
|
||||||
|
});
|
||||||
|
|
||||||
if (!target && isGov) {
|
if (!target && isGov) {
|
||||||
// Government fits nowhere on its own — try to preempt commercial
|
// Government fits nowhere on its own — try to preempt commercial
|
||||||
// on each train (earliest first) until one frees enough room.
|
// on each corridor-matching train (earliest first) until one frees room.
|
||||||
for (const t of trains) {
|
for (const t of trains) {
|
||||||
t.budget = await this.preemptForGovernment(
|
const leg = legOn(t);
|
||||||
|
if (!leg) continue;
|
||||||
|
const freed = await this.preemptForGovernment(
|
||||||
t.id,
|
t.id,
|
||||||
need,
|
need,
|
||||||
|
leg,
|
||||||
t.budget,
|
t.budget,
|
||||||
wagonLengths,
|
wagonLengths,
|
||||||
);
|
);
|
||||||
if (this.fits(need, t.budget)) {
|
if (freed) {
|
||||||
target = t;
|
target = t;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1170,10 +1191,15 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// A consolidated pair is placed whole or not at all — never split.
|
// A consolidated pair is placed whole or not at all — never split.
|
||||||
if (!isPair) {
|
if (!isPair) {
|
||||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||||
// partial-capacity offer on the train with the most free wagons.
|
// partial-capacity offer on the train with the most free wagons on the
|
||||||
const partialTarget = [...trains]
|
// booking's own leg.
|
||||||
.filter((t) => t.budget.wagons >= 1)
|
const partialTarget = trains
|
||||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
.map((t) => {
|
||||||
|
const leg = legOn(t);
|
||||||
|
return leg ? { t, leg, room: t.budget.remainingFor(leg) } : null;
|
||||||
|
})
|
||||||
|
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
||||||
|
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
||||||
if (
|
if (
|
||||||
partialTarget &&
|
partialTarget &&
|
||||||
!booking.isGovernment &&
|
!booking.isGovernment &&
|
||||||
@@ -1183,13 +1209,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
) {
|
) {
|
||||||
const offered = await this.tryPartialOffer(
|
const offered = await this.tryPartialOffer(
|
||||||
booking,
|
booking,
|
||||||
partialTarget.id,
|
partialTarget.t.id,
|
||||||
partialTarget.budget,
|
partialTarget.room,
|
||||||
need,
|
need,
|
||||||
);
|
);
|
||||||
if (offered) {
|
if (offered) {
|
||||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
partialTarget.t.budget.subtract(offered, partialTarget.leg);
|
||||||
partialTarget.armed = true;
|
partialTarget.t.armed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1208,11 +1234,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (partner) await this.reserve(partner, target.id);
|
if (partner) await this.reserve(partner, target.id);
|
||||||
target.armed = true;
|
target.armed = true;
|
||||||
}
|
}
|
||||||
target.budget = this.subtract(target.budget, need);
|
target.budget.subtract(need, legOn(target)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const t of trains) {
|
for (const t of trains) {
|
||||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
|
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
|
||||||
if (t.armed) this.armSettle(t.id);
|
if (t.armed) this.armSettle(t.id);
|
||||||
void this.triggerWagonAllocation(t.id);
|
void this.triggerWagonAllocation(t.id);
|
||||||
}
|
}
|
||||||
@@ -1414,10 +1440,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
"Target schedule is not accepting bookings",
|
"Target schedule is not accepting bookings",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
const stops = await this.stopsForSchedule(schedule);
|
||||||
schedule.originStationId !== booking.originYardId ||
|
const fromIdx = stops.indexOf(booking.originYardId);
|
||||||
schedule.destinationStationId !== booking.destinationYardId
|
const toIdx = stops.indexOf(booking.destinationYardId);
|
||||||
) {
|
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Target schedule is not on the booking route",
|
"Target schedule is not on the booking route",
|
||||||
);
|
);
|
||||||
@@ -1461,13 +1487,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// ---- intercity ride-along API ---------------------------------------------
|
// ---- intercity ride-along API ---------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remaining capacity budget (wagons / weight / length) for a schedule, and
|
* Remaining corridor capacity budget (per-edge wagons / weight / length) for
|
||||||
* the per-booking need calculator — exposed for the intercity accept flow,
|
* a schedule, and the per-booking need calculator — exposed for the intercity
|
||||||
* which reserves ride-along bookings onto import/export trains outside the
|
* accept flow, which reserves ride-along bookings onto import/export trains
|
||||||
* batch engine.
|
* outside the batch engine. Segment-based: an intercity booking fits whenever
|
||||||
|
* ITS leg has room, even if the train is full on other legs.
|
||||||
*/
|
*/
|
||||||
async intercityCapacity(scheduleId: string): Promise<{
|
async intercityCapacity(scheduleId: string): Promise<{
|
||||||
budget: Capacity;
|
budget: CorridorBudget;
|
||||||
needFor: (booking: Booking) => Capacity;
|
needFor: (booking: Booking) => Capacity;
|
||||||
} | null> {
|
} | null> {
|
||||||
const schedule =
|
const schedule =
|
||||||
@@ -1477,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const rules = await this.loadGlobalRules();
|
const rules = await this.loadGlobalRules();
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||||
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
|
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1635,13 +1662,17 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
/**
|
/**
|
||||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||||
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
||||||
|
* Only victims whose legs overlap the government booking's leg actually free useful
|
||||||
|
* room, so others are skipped. Mutates `budget`; returns whether the need now fits.
|
||||||
*/
|
*/
|
||||||
private async preemptForGovernment(
|
private async preemptForGovernment(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
need: Capacity,
|
need: Capacity,
|
||||||
budget: Capacity,
|
leg: CorridorLeg,
|
||||||
|
budget: CorridorBudget,
|
||||||
wagonLengths: WagonLengths,
|
wagonLengths: WagonLengths,
|
||||||
): Promise<Capacity> {
|
): Promise<boolean> {
|
||||||
|
if (budget.fits(need, leg)) return true;
|
||||||
const reservedCommercial = (
|
const reservedCommercial = (
|
||||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||||
).filter((b) => !b.isGovernment);
|
).filter((b) => !b.isGovernment);
|
||||||
@@ -1655,9 +1686,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
|
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let freed = budget;
|
|
||||||
for (const victim of candidates) {
|
for (const victim of candidates) {
|
||||||
if (this.fits(need, freed)) break;
|
if (budget.fits(need, leg)) break;
|
||||||
|
const victimLeg = budget.legForYards(
|
||||||
|
victim.originYardId,
|
||||||
|
victim.destinationYardId,
|
||||||
|
);
|
||||||
|
// Displacing a booking on a disjoint leg frees nothing the government
|
||||||
|
// booking can use — don't kill it for nothing.
|
||||||
|
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
|
||||||
|
if (!overlaps) continue;
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
@@ -1680,9 +1718,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.notifier.displaced(victim);
|
this.notifier.displaced(victim);
|
||||||
freed = this.add(freed, this.needFor(victim, wagonLengths));
|
budget.add(this.needFor(victim, wagonLengths), victimLeg);
|
||||||
}
|
}
|
||||||
return freed;
|
return budget.fits(need, leg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- capacity helpers -----------------------------------------------------
|
// ---- capacity helpers -----------------------------------------------------
|
||||||
@@ -1790,22 +1828,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private subtract(budget: Capacity, need: Capacity): Capacity {
|
|
||||||
return {
|
|
||||||
wagons: budget.wagons - need.wagons,
|
|
||||||
weightTons: budget.weightTons - need.weightTons,
|
|
||||||
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private add(budget: Capacity, freed: Capacity): Capacity {
|
|
||||||
return {
|
|
||||||
wagons: budget.wagons + freed.wagons,
|
|
||||||
weightTons: budget.weightTons + freed.weightTons,
|
|
||||||
lengthMeters: budget.lengthMeters + freed.lengthMeters,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
|
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
|
||||||
private async capacityLimits(
|
private async capacityLimits(
|
||||||
locomotive: Locomotive,
|
locomotive: Locomotive,
|
||||||
@@ -1883,37 +1905,68 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
.findOne({ where: {} });
|
.findOne({ where: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
|
/**
|
||||||
private async remainingCapacity(
|
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||||
|
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||||
|
*/
|
||||||
|
private async stopsForSchedule(schedule: TrainSchedule): Promise<string[]> {
|
||||||
|
let milestoneYards: string[] | null = null;
|
||||||
|
if (schedule.routeId) {
|
||||||
|
const milestones = await this.dataSource
|
||||||
|
.getRepository(RouteMilestone)
|
||||||
|
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||||
|
if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId);
|
||||||
|
}
|
||||||
|
return stopYardsFor(
|
||||||
|
milestoneYards,
|
||||||
|
schedule.originStationId,
|
||||||
|
schedule.destinationStationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remaining capacity per corridor edge = hard caps minus what allocated +
|
||||||
|
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
||||||
|
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
||||||
|
*/
|
||||||
|
private async remainingBudget(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
limits: Capacity,
|
limits: Capacity,
|
||||||
wagonLengths: WagonLengths,
|
wagonLengths: WagonLengths,
|
||||||
): Promise<Capacity> {
|
): Promise<CorridorBudget> {
|
||||||
|
const stops = await this.stopsForSchedule(schedule);
|
||||||
|
const budget = new CorridorBudget(stops, limits);
|
||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const allocated = (schedule.scheduleBookings ?? [])
|
||||||
.map((sb) => sb.booking)
|
.map((sb) => sb.booking)
|
||||||
.filter((b): b is Booking => Boolean(b));
|
.filter((b): b is Booking => Boolean(b));
|
||||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||||
schedule.id,
|
schedule.id,
|
||||||
);
|
);
|
||||||
const used = [...allocated, ...reserved].reduce<Capacity>(
|
for (const b of [...allocated, ...reserved]) {
|
||||||
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
|
budget.subtract(
|
||||||
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
|
this.needFor(b, wagonLengths),
|
||||||
);
|
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||||
return this.subtract(limits, used);
|
);
|
||||||
|
}
|
||||||
|
return budget;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** maxWagons minus wagons already taken by allocated + reserved bookings. */
|
/**
|
||||||
|
* Wagon slots still boardable somewhere on the corridor (most-open edge).
|
||||||
|
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
|
||||||
|
*/
|
||||||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
.map((sb) => sb.booking)
|
const budget = await this.remainingBudget(
|
||||||
.filter((b): b is Booking => Boolean(b));
|
schedule,
|
||||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
{
|
||||||
schedule.id,
|
wagons: schedule.maxWagons ?? 0,
|
||||||
|
weightTons: Number.POSITIVE_INFINITY,
|
||||||
|
lengthMeters: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
wagonLengths,
|
||||||
);
|
);
|
||||||
const used =
|
return budget.maxRemaining().wagons;
|
||||||
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
|
|
||||||
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
|
|
||||||
return (schedule.maxWagons ?? 0) - used;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setWindow(
|
async setWindow(
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
Optional,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||||
|
*
|
||||||
|
* A booking rides only its own origin→destination leg, so "dispatched" and
|
||||||
|
* "arrived" are per-booking facts confirmed by the yard operator, not train
|
||||||
|
* facts: load at the booking's origin yard (PAID → IN_TRANSIT, loadedAt) and
|
||||||
|
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
|
||||||
|
* → COMPLETED for intercity), possibly long before the train's final arrival.
|
||||||
|
* Both are gated on the train's latest recorded checkpoint being at that yard.
|
||||||
|
*
|
||||||
|
* Unloading also settles the physical wagons: each wagon that alights with the
|
||||||
|
* booking is released at that yard and the move is written to the
|
||||||
|
* wagon_movements ledger.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingJourneyService {
|
||||||
|
private readonly logger = new Logger(BookingJourneyService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Statuses from which a booking may be loaded (gov bookings don't prepay). */
|
||||||
|
private canLoad(booking: Booking): boolean {
|
||||||
|
if (booking.status === 'PAID') return true;
|
||||||
|
return booking.isGovernment && booking.status === 'APPROVED';
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||||
|
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||||
|
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
||||||
|
throw new BadRequestException('Booking is already loaded');
|
||||||
|
}
|
||||||
|
if (!this.canLoad(booking)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking must be paid before loading (currently ${booking.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(Booking).update(bookingId, {
|
||||||
|
status: 'IN_TRANSIT',
|
||||||
|
loadedAt: now,
|
||||||
|
loadedByUserId: userId ?? null,
|
||||||
|
} as never);
|
||||||
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||||
|
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
||||||
|
// bookings (intercity) and already-completed codes.
|
||||||
|
void this.completeMilestones(booking, [
|
||||||
|
'CARGO_ARRIVED',
|
||||||
|
'READY_FOR_LOADING',
|
||||||
|
'LOADED',
|
||||||
|
...(booking.tradeDirection === 'IMPORT'
|
||||||
|
? ['DEPARTED_FROM_DJIBOUTI']
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
|
? ['DEPARTED_TO_DJIBOUTI']
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { bookingId, status: 'IN_TRANSIT' as const, loadedAt: now.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||||
|
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||||
|
if (booking.status !== 'IN_TRANSIT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||||
|
|
||||||
|
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||||
|
// export continue into clearance, keyed on the booking's own arrival.
|
||||||
|
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
||||||
|
const now = new Date();
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(Booking).update(bookingId, {
|
||||||
|
status: nextStatus,
|
||||||
|
arrivedAt: now,
|
||||||
|
arrivedByUserId: userId ?? null,
|
||||||
|
} as never);
|
||||||
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
||||||
|
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||||
|
void this.completeMilestones(booking, [
|
||||||
|
...(booking.tradeDirection === 'IMPORT'
|
||||||
|
? ['ARRIVED_ETHIOPIA']
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
|
? ['ARRIVED_AT_DJIBOUTI']
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { bookingId, status: nextStatus, arrivedAt: now.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-yard operator worklist for a schedule: which bookings board / alight at
|
||||||
|
* each stop, with their journey state, so the yard operator at Dire sees
|
||||||
|
* exactly what to load and unload when the train is there.
|
||||||
|
*/
|
||||||
|
async listYardWork(scheduleId: string) {
|
||||||
|
const schedule = await this.getSchedule(scheduleId);
|
||||||
|
const bookings = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
|
.innerJoin(
|
||||||
|
'freight.train_schedule_bookings',
|
||||||
|
'tsb',
|
||||||
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
||||||
|
{ scheduleId },
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const latest = await this.latestCheckpoint(scheduleId);
|
||||||
|
const yardIds = [
|
||||||
|
...new Set(
|
||||||
|
bookings.flatMap((b) => [b.originYardId, b.destinationYardId]).filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const yards = yardIds.length
|
||||||
|
? await this.dataSource.getRepository(Yard).find({ where: { id: In(yardIds) } })
|
||||||
|
: [];
|
||||||
|
const yardById = new Map(yards.map((y) => [y.id, y]));
|
||||||
|
const yardLabel = (id: string) =>
|
||||||
|
yardById.get(id)?.label ?? yardById.get(id)?.code ?? id;
|
||||||
|
|
||||||
|
const mapBooking = (b: Booking) => ({
|
||||||
|
id: b.id,
|
||||||
|
reference: b.reference,
|
||||||
|
status: b.status,
|
||||||
|
tradeDirection: b.tradeDirection,
|
||||||
|
isGovernment: b.isGovernment,
|
||||||
|
customer: b.company?.name ?? 'Unknown customer',
|
||||||
|
originYardId: b.originYardId,
|
||||||
|
destinationYardId: b.destinationYardId,
|
||||||
|
origin: yardLabel(b.originYardId),
|
||||||
|
destination: yardLabel(b.destinationYardId),
|
||||||
|
loadedAt: b.loadedAt?.toISOString() ?? null,
|
||||||
|
arrivedAt: b.arrivedAt?.toISOString() ?? null,
|
||||||
|
canLoad: !b.loadedAt && this.canLoad(b),
|
||||||
|
canUnload: b.status === 'IN_TRANSIT',
|
||||||
|
});
|
||||||
|
|
||||||
|
const byYard = new Map<
|
||||||
|
string,
|
||||||
|
{ yardId: string; yard: string; toLoad: ReturnType<typeof mapBooking>[]; toUnload: ReturnType<typeof mapBooking>[] }
|
||||||
|
>();
|
||||||
|
const bucket = (yardId: string) => {
|
||||||
|
let entry = byYard.get(yardId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { yardId, yard: yardLabel(yardId), toLoad: [], toUnload: [] };
|
||||||
|
byYard.set(yardId, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
for (const b of bookings) {
|
||||||
|
bucket(b.originYardId).toLoad.push(mapBooking(b));
|
||||||
|
bucket(b.destinationYardId).toUnload.push(mapBooking(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleId,
|
||||||
|
scheduleStatus: schedule.status,
|
||||||
|
trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId),
|
||||||
|
yards: [...byYard.values()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk fallback at the train's FINAL arrival: any booking destined for the
|
||||||
|
* final yard that operators didn't unload individually gets its per-booking
|
||||||
|
* arrival stamped now, so nothing stays stuck. Mid-corridor bookings are NOT
|
||||||
|
* touched — their arrival is their own unload. Returns the affected ids.
|
||||||
|
*/
|
||||||
|
async autoArriveAtFinalYard(
|
||||||
|
manager: EntityManager,
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
now: Date,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows: Array<{ id: string; trade_direction: string }> = await manager.query(
|
||||||
|
`UPDATE freight.bookings b
|
||||||
|
SET status = CASE WHEN b.trade_direction = 'DOMESTIC' THEN 'COMPLETED' ELSE 'ARRIVED' END,
|
||||||
|
scheduling_status = 'DISPATCHED',
|
||||||
|
arrived_at = COALESCE(b.arrived_at, $3),
|
||||||
|
loaded_at = COALESCE(b.loaded_at, b.created_at)
|
||||||
|
FROM freight.train_schedule_bookings tsb
|
||||||
|
WHERE tsb.booking_id = b.id
|
||||||
|
AND tsb.train_schedule_id = $1
|
||||||
|
AND tsb.deleted_at IS NULL
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.destination_yard_id = $2
|
||||||
|
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED', 'ARRIVED', 'DELIVERED')
|
||||||
|
RETURNING b.id, b.trade_direction`,
|
||||||
|
[schedule.id, schedule.destinationStationId, now],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||||
|
const schedule = await this.dataSource
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.findOne({ where: { id: scheduleId } });
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getScheduleBooking(scheduleId: string, bookingId: string) {
|
||||||
|
const schedule = await this.getSchedule(scheduleId);
|
||||||
|
const booking = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: bookingId } });
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
if (booking.trainScheduleId !== scheduleId) {
|
||||||
|
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||||
|
}
|
||||||
|
return { schedule, booking };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async latestCheckpoint(scheduleId: string): Promise<TrainCheckpointEvent | null> {
|
||||||
|
return this.dataSource.getRepository(TrainCheckpointEvent).findOne({
|
||||||
|
where: { trainScheduleId: scheduleId },
|
||||||
|
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||||
|
* or — for a booking boarding at the train's own origin — when the train has
|
||||||
|
* not recorded any checkpoint yet (still sitting at its origin).
|
||||||
|
*/
|
||||||
|
private async assertTrainAtYard(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
yardId: string,
|
||||||
|
side: 'origin' | 'destination',
|
||||||
|
): Promise<void> {
|
||||||
|
const latest = await this.latestCheckpoint(schedule.id);
|
||||||
|
if (!latest) {
|
||||||
|
if (side === 'origin' && schedule.originStationId === yardId) return;
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Train has not reached this yard yet — record its checkpoint first',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (latest.yardId !== yardId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Train's last recorded position is not at the booking's ${side} yard`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setAllocationStatuses(
|
||||||
|
manager: EntityManager,
|
||||||
|
scheduleId: string,
|
||||||
|
bookingId: string,
|
||||||
|
status: 'LOADED' | 'DEPARTED',
|
||||||
|
): Promise<void> {
|
||||||
|
const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
|
||||||
|
if (!allocations.length) return;
|
||||||
|
await manager
|
||||||
|
.getRepository(WagonBookingAllocation)
|
||||||
|
.update({ id: In(allocations.map((a) => a.id)) }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async allocationsForBooking(
|
||||||
|
manager: EntityManager,
|
||||||
|
scheduleId: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Array<WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }>> {
|
||||||
|
return manager
|
||||||
|
.getRepository(WagonBookingAllocation)
|
||||||
|
.createQueryBuilder('alloc')
|
||||||
|
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
||||||
|
.innerJoin(
|
||||||
|
'freight.train_schedules',
|
||||||
|
'schedule',
|
||||||
|
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||||
|
{ scheduleId },
|
||||||
|
)
|
||||||
|
.where('alloc.booking_id = :bookingId', { bookingId })
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On unload: write the wagon_movements ledger rows (board yard → unload yard,
|
||||||
|
* kind LOADED) for the booking's pinned wagons, and release each wagon whose
|
||||||
|
* slot alights here — it detaches, stays at this yard, and becomes Available
|
||||||
|
* (dynamic consist). Wagons shared with a still-loaded consolidated partner
|
||||||
|
* stay pinned until the last booking on the slot unloads.
|
||||||
|
*/
|
||||||
|
private async settleWagonsOnUnload(
|
||||||
|
manager: EntityManager,
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
booking: Booking,
|
||||||
|
now: Date,
|
||||||
|
userId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const allocations = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
||||||
|
for (const alloc of allocations) {
|
||||||
|
const slot = alloc.trainSetWagon;
|
||||||
|
if (!slot?.physicalWagonId) continue;
|
||||||
|
|
||||||
|
const boardYardId = slot.boardYardId ?? schedule.originStationId;
|
||||||
|
await manager.getRepository(WagonMovement).save(
|
||||||
|
manager.getRepository(WagonMovement).create({
|
||||||
|
wagonId: slot.physicalWagonId,
|
||||||
|
fromYardId: boardYardId,
|
||||||
|
toYardId: booking.destinationYardId,
|
||||||
|
trainScheduleId: schedule.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
kind: Freight.WagonMovementKind.Loaded,
|
||||||
|
movedByUserId: userId,
|
||||||
|
occurredAt: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Detach only when this yard is where the slot's leg ends and no other
|
||||||
|
// booking on the wagon is still in transit.
|
||||||
|
const slotAlightYardId = slot.alightYardId ?? schedule.destinationStationId;
|
||||||
|
if (slotAlightYardId !== booking.destinationYardId) continue;
|
||||||
|
const siblings = await manager
|
||||||
|
.getRepository(WagonBookingAllocation)
|
||||||
|
.createQueryBuilder('alloc')
|
||||||
|
.innerJoin('alloc.booking', 'b')
|
||||||
|
.where('alloc.train_set_wagon_id = :slotId', { slotId: slot.id })
|
||||||
|
.andWhere('alloc.booking_id != :bookingId', { bookingId: booking.id })
|
||||||
|
.andWhere(`b.status = 'IN_TRANSIT'`)
|
||||||
|
.getCount();
|
||||||
|
if (siblings > 0) continue;
|
||||||
|
|
||||||
|
await manager.getRepository(TrainSetWagon).update(slot.id, { status: 'DEPARTED' });
|
||||||
|
const wagon = await manager
|
||||||
|
.getRepository(Wagon)
|
||||||
|
.findOne({ where: { id: slot.physicalWagonId } });
|
||||||
|
// Only settle a wagon still bound to this schedule (it may have been
|
||||||
|
// re-pinned elsewhere already).
|
||||||
|
if (wagon && wagon.currentTrainScheduleId === schedule.id) {
|
||||||
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
|
currentYardId: booking.destinationYardId,
|
||||||
|
currentTrainScheduleId: null,
|
||||||
|
trainSetWagonId: null,
|
||||||
|
status: Freight.WagonStatus.Available,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeMilestones(booking: Booking, codes: string[]): Promise<void> {
|
||||||
|
if (!this.milestoneService || !codes.length) return;
|
||||||
|
for (const code of codes) {
|
||||||
|
try {
|
||||||
|
await this.milestoneService.completeByDocTrigger({ bookingId: booking.id }, code);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Milestone ${code} completion failed for booking ${booking.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Segment (leg) aware capacity accounting for corridor bookings.
|
||||||
|
*
|
||||||
|
* A train's route is an ordered list of stops; a booking occupies only the
|
||||||
|
* edges between its own origin and destination. Capacity (wagons / weight /
|
||||||
|
* length) is therefore tracked PER EDGE, not per train: two bookings whose
|
||||||
|
* legs don't overlap (Addis→Dire and Dire→Djibouti) consume the same wagon
|
||||||
|
* budget on disjoint edges and can share physical wagons.
|
||||||
|
*
|
||||||
|
* Legacy schedules without route milestones degrade to a single-edge corridor
|
||||||
|
* ([origin, destination]) where this is exactly the old train-wide math.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Capacity {
|
||||||
|
wagons: number;
|
||||||
|
weightTons: number;
|
||||||
|
lengthMeters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-open edge span along the stop list: occupies edges [fromEdge, toEdge). */
|
||||||
|
export interface CorridorLeg {
|
||||||
|
fromEdge: number;
|
||||||
|
toEdge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCapacity(a: Capacity, b: Capacity): Capacity {
|
||||||
|
return {
|
||||||
|
wagons: a.wagons + b.wagons,
|
||||||
|
weightTons: a.weightTons + b.weightTons,
|
||||||
|
lengthMeters: a.lengthMeters + b.lengthMeters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subtractCapacity(a: Capacity, b: Capacity): Capacity {
|
||||||
|
return {
|
||||||
|
wagons: a.wagons - b.wagons,
|
||||||
|
weightTons: a.weightTons - b.weightTons,
|
||||||
|
lengthMeters: a.lengthMeters - b.lengthMeters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capacityFits(need: Capacity, budget: Capacity): boolean {
|
||||||
|
return (
|
||||||
|
need.wagons <= budget.wagons &&
|
||||||
|
need.weightTons <= budget.weightTons &&
|
||||||
|
need.lengthMeters <= budget.lengthMeters
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered stop yard ids for a schedule. Route milestones (already ordered by
|
||||||
|
* sequence) when there are at least two; otherwise the schedule's own
|
||||||
|
* origin/destination pair — the legacy two-stop pseudo-route.
|
||||||
|
*/
|
||||||
|
export function stopYardsFor(
|
||||||
|
milestoneYardIdsInOrder: string[] | null | undefined,
|
||||||
|
originStationId: string,
|
||||||
|
destinationStationId: string,
|
||||||
|
): string[] {
|
||||||
|
if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) {
|
||||||
|
return milestoneYardIdsInOrder;
|
||||||
|
}
|
||||||
|
return [originStationId, destinationStationId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-edge capacity budget along a schedule's stop list. */
|
||||||
|
export class CorridorBudget {
|
||||||
|
private readonly edges: Capacity[];
|
||||||
|
private readonly stopIndex: Map<string, number>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly stops: string[],
|
||||||
|
initial: Capacity,
|
||||||
|
) {
|
||||||
|
const edgeCount = Math.max(1, stops.length - 1);
|
||||||
|
this.edges = Array.from({ length: edgeCount }, () => ({ ...initial }));
|
||||||
|
this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The leg between two stops, or null when they aren't on this corridor in order. */
|
||||||
|
legOf(originYardId: string, destinationYardId: string): CorridorLeg | null {
|
||||||
|
const from = this.stopIndex.get(originYardId);
|
||||||
|
const to = this.stopIndex.get(destinationYardId);
|
||||||
|
if (from == null || to == null || from >= to) return null;
|
||||||
|
return { fromEdge: from, toEdge: to };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every edge — for whole-route consumers and unknown-leg fallbacks. */
|
||||||
|
fullLeg(): CorridorLeg {
|
||||||
|
return { fromEdge: 0, toEdge: this.edges.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leg a booking occupies; bookings whose yards aren't on the corridor
|
||||||
|
* (legacy data drift) conservatively occupy the whole route so capacity is
|
||||||
|
* never double-booked against them.
|
||||||
|
*/
|
||||||
|
legForYards(originYardId: string, destinationYardId: string): CorridorLeg {
|
||||||
|
return this.legOf(originYardId, destinationYardId) ?? this.fullLeg();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remaining capacity usable by this leg = min across its edges. */
|
||||||
|
remainingFor(leg: CorridorLeg): Capacity {
|
||||||
|
let min = { ...this.edges[leg.fromEdge] };
|
||||||
|
for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) {
|
||||||
|
const e = this.edges[i];
|
||||||
|
min = {
|
||||||
|
wagons: Math.min(min.wagons, e.wagons),
|
||||||
|
weightTons: Math.min(min.weightTons, e.weightTons),
|
||||||
|
lengthMeters: Math.min(min.lengthMeters, e.lengthMeters),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
fits(need: Capacity, leg: CorridorLeg): boolean {
|
||||||
|
return capacityFits(need, this.remainingFor(leg));
|
||||||
|
}
|
||||||
|
|
||||||
|
subtract(need: Capacity, leg: CorridorLeg): void {
|
||||||
|
for (let i = leg.fromEdge; i < leg.toEdge; i++) {
|
||||||
|
this.edges[i] = subtractCapacity(this.edges[i], need);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(freed: Capacity, leg: CorridorLeg): void {
|
||||||
|
for (let i = leg.fromEdge; i < leg.toEdge; i++) {
|
||||||
|
this.edges[i] = addCapacity(this.edges[i], freed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most open edge — when even this has no wagon slots left, nothing can
|
||||||
|
* board anywhere and the schedule's window is genuinely FULL. (A train can be
|
||||||
|
* full on one leg while another still has room, so train-wide FULL keys on
|
||||||
|
* the max, not the min.)
|
||||||
|
*/
|
||||||
|
maxRemaining(): Capacity {
|
||||||
|
return this.edges.reduce(
|
||||||
|
(max, e) => ({
|
||||||
|
wagons: Math.max(max.wagons, e.wagons),
|
||||||
|
weightTons: Math.max(max.weightTons, e.weightTons),
|
||||||
|
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
||||||
|
}),
|
||||||
|
{ ...this.edges[0] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ import { DataSource } from 'typeorm';
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { BookingBatchService, type Capacity } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { BookingJourneyService } from './booking-journey.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
|
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
|
||||||
@@ -32,6 +32,7 @@ export class IntercityService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
|
private readonly bookingJourneyService: BookingJourneyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,13 +53,20 @@ export class IntercityService {
|
|||||||
return {
|
return {
|
||||||
scheduleId,
|
scheduleId,
|
||||||
routeId: schedule.routeId ?? null,
|
routeId: schedule.routeId ?? null,
|
||||||
remaining: capacity?.budget ?? null,
|
// Segment-based: "remaining" is the most-open edge; each candidate's
|
||||||
|
// `fits` is judged against ITS OWN leg, so a booking on a free leg fits
|
||||||
|
// even when the train is full elsewhere.
|
||||||
|
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||||
candidates: waiting.map((booking) => {
|
candidates: waiting.map((booking) => {
|
||||||
const need = capacity?.needFor(booking) ?? null;
|
const need = capacity?.needFor(booking) ?? null;
|
||||||
|
const leg = capacity?.budget.legOf(
|
||||||
|
booking.originYardId,
|
||||||
|
booking.destinationYardId,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...this.mapBooking(booking),
|
...this.mapBooking(booking),
|
||||||
need,
|
need,
|
||||||
fits: need && capacity ? fits(need, capacity.budget) : false,
|
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
accepted: accepted.map((booking) => ({
|
accepted: accepted.map((booking) => ({
|
||||||
@@ -94,7 +102,7 @@ export class IntercityService {
|
|||||||
|
|
||||||
const accepted: string[] = [];
|
const accepted: string[] = [];
|
||||||
const rejected: Array<{ bookingId: string; reason: string }> = [];
|
const rejected: Array<{ bookingId: string; reason: string }> = [];
|
||||||
let budget = capacity.budget;
|
const budget = capacity.budget;
|
||||||
|
|
||||||
for (const bookingId of bookingIds) {
|
for (const bookingId of bookingIds) {
|
||||||
const booking = await this.dataSource
|
const booking = await this.dataSource
|
||||||
@@ -110,45 +118,35 @@ export class IntercityService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const need = capacity.needFor(booking);
|
const need = capacity.needFor(booking);
|
||||||
if (!fits(need, budget)) {
|
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
|
// Segment-based: only the booking's own leg must have room, so an
|
||||||
|
// intercity booking still boards a train that is full on other legs.
|
||||||
|
if (!leg || !budget.fits(need, leg)) {
|
||||||
rejected.push({
|
rejected.push({
|
||||||
bookingId,
|
bookingId,
|
||||||
reason: 'Does not fit the remaining wagon/weight/length capacity',
|
reason:
|
||||||
|
'Does not fit the remaining wagon/weight/length capacity on its leg',
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
|
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
|
||||||
budget = subtract(budget, need);
|
budget.subtract(need, leg);
|
||||||
accepted.push(bookingId);
|
accepted.push(bookingId);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
|
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accepted, rejected, remaining: budget };
|
return { accepted, rejected, remaining: budget.maxRemaining() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
|
* Mark an accepted intercity booking's cargo as loaded. Delegates to the
|
||||||
* the train is physically at the booking's origin yard: either it has not
|
* shared per-booking journey flow (same checkpoint gating as import/export).
|
||||||
* departed yet and the booking boards at the train's own origin, or the
|
|
||||||
* latest recorded checkpoint is at the booking's origin yard.
|
|
||||||
*/
|
*/
|
||||||
async loadBooking(scheduleId: string, bookingId: string) {
|
async loadBooking(scheduleId: string, bookingId: string) {
|
||||||
const { schedule, booking } = await this.getAcceptedBooking(
|
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
|
||||||
scheduleId,
|
return this.bookingJourneyService.loadBooking(scheduleId, bookingId);
|
||||||
bookingId,
|
|
||||||
);
|
|
||||||
if (booking.status !== 'PAID') {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Booking must be paid before loading (currently ${booking.status})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
|
||||||
await this.dataSource
|
|
||||||
.getRepository(Booking)
|
|
||||||
.update(bookingId, { status: 'IN_TRANSIT' });
|
|
||||||
return { bookingId, status: 'IN_TRANSIT' as const };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,20 +154,8 @@ export class IntercityService {
|
|||||||
* requires the latest checkpoint to be at that yard. Completes the booking.
|
* requires the latest checkpoint to be at that yard. Completes the booking.
|
||||||
*/
|
*/
|
||||||
async unloadBooking(scheduleId: string, bookingId: string) {
|
async unloadBooking(scheduleId: string, bookingId: string) {
|
||||||
const { schedule, booking } = await this.getAcceptedBooking(
|
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
|
||||||
scheduleId,
|
return this.bookingJourneyService.unloadBooking(scheduleId, bookingId);
|
||||||
bookingId,
|
|
||||||
);
|
|
||||||
if (booking.status !== 'IN_TRANSIT') {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
|
||||||
await this.dataSource
|
|
||||||
.getRepository(Booking)
|
|
||||||
.update(bookingId, { status: 'COMPLETED' });
|
|
||||||
return { bookingId, status: 'COMPLETED' as const };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ---------------------------------------------------------------
|
// ---- helpers ---------------------------------------------------------------
|
||||||
@@ -298,36 +284,6 @@ export class IntercityService {
|
|||||||
return { schedule, booking };
|
return { schedule, booking };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
|
||||||
* or — for a booking boarding at the train's own origin — when the train has
|
|
||||||
* not recorded any checkpoint yet (still sitting at its origin).
|
|
||||||
*/
|
|
||||||
private async assertTrainAtYard(
|
|
||||||
schedule: TrainSchedule,
|
|
||||||
yardId: string,
|
|
||||||
side: 'origin' | 'destination',
|
|
||||||
): Promise<void> {
|
|
||||||
const latest = await this.dataSource
|
|
||||||
.getRepository(TrainCheckpointEvent)
|
|
||||||
.findOne({
|
|
||||||
where: { trainScheduleId: schedule.id },
|
|
||||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!latest) {
|
|
||||||
if (side === 'origin' && schedule.originStationId === yardId) return;
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Train has not reached this yard yet — record its checkpoint first',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (latest.yardId !== yardId) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Train's last recorded position is not at the booking's ${side} yard`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapBooking(booking: Booking) {
|
private mapBooking(booking: Booking) {
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
@@ -350,18 +306,3 @@ export class IntercityService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fits(need: Capacity, budget: Capacity): boolean {
|
|
||||||
return (
|
|
||||||
need.wagons <= budget.wagons &&
|
|
||||||
need.weightTons <= budget.weightTons &&
|
|
||||||
need.lengthMeters <= budget.lengthMeters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function subtract(budget: Capacity, need: Capacity): Capacity {
|
|
||||||
return {
|
|
||||||
wagons: budget.wagons - need.wagons,
|
|
||||||
weightTons: budget.weightTons - need.weightTons,
|
|
||||||
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.d
|
|||||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
|
import { BookingJourneyService } from "./booking-journey.service";
|
||||||
import { BookingWindowService } from "./booking-window.service";
|
import { BookingWindowService } from "./booking-window.service";
|
||||||
import { IntercityService } from "./intercity.service";
|
import { IntercityService } from "./intercity.service";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../billing/billing.service";
|
||||||
@@ -60,6 +61,7 @@ export class TrainSchedulingController {
|
|||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
private readonly bookingWindowService: BookingWindowService,
|
private readonly bookingWindowService: BookingWindowService,
|
||||||
private readonly intercityService: IntercityService,
|
private readonly intercityService: IntercityService,
|
||||||
|
private readonly bookingJourneyService: BookingJourneyService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -432,6 +434,42 @@ export class TrainSchedulingController {
|
|||||||
return this.intercityService.acceptBookings(id, dto.bookingIds);
|
return this.intercityService.acceptBookings(id, dto.bookingIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("schedules/:id/yard-work")
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Per-yard operator worklist: which bookings board/alight at each stop, with journey state",
|
||||||
|
})
|
||||||
|
getYardWork(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.bookingJourneyService.listYardWork(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/bookings/:bookingId/load")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
|
||||||
|
})
|
||||||
|
loadScheduleBooking(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||||
|
) {
|
||||||
|
return this.bookingJourneyService.loadBooking(id, bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/bookings/:bookingId/unload")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
|
||||||
|
})
|
||||||
|
unloadScheduleBooking(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||||
|
) {
|
||||||
|
return this.bookingJourneyService.unloadBooking(id, bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/intercity/:bookingId/load")
|
@Post("schedules/:id/intercity/:bookingId/load")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
|||||||
import { BookingWindowService } from './booking-window.service';
|
import { BookingWindowService } from './booking-window.service';
|
||||||
import { IntercityService } from './intercity.service';
|
import { IntercityService } from './intercity.service';
|
||||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||||
|
import { BookingJourneyService } from './booking-journey.service';
|
||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||||
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { ContractsModule } from '../contracts/contracts.module';
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
@@ -51,6 +53,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
TrainCheckpointEvent,
|
TrainCheckpointEvent,
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
BookingBatchOffer,
|
BookingBatchOffer,
|
||||||
|
WagonMovement,
|
||||||
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
|
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
|
||||||
Session,
|
Session,
|
||||||
]),
|
]),
|
||||||
@@ -77,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
BookingWindowService,
|
BookingWindowService,
|
||||||
BookingSplitService,
|
BookingSplitService,
|
||||||
IntercityService,
|
IntercityService,
|
||||||
|
BookingJourneyService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TrainSchedulingService,
|
TrainSchedulingService,
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ describe('TrainSchedulingService', () => {
|
|||||||
htmlToPdfBuffer: jest.fn(),
|
htmlToPdfBuffer: jest.fn(),
|
||||||
} as never,
|
} as never,
|
||||||
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
||||||
|
{
|
||||||
|
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||||
|
} as never, // bookingJourneyService
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFleetWagons = [
|
const defaultFleetWagons = [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
SchedulingStatus,
|
SchedulingStatus,
|
||||||
TrainCheckpointKind,
|
TrainCheckpointKind,
|
||||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||||
|
WagonMovementKind,
|
||||||
WagonStatus,
|
WagonStatus,
|
||||||
} from '@edr/types';
|
} from '@edr/types';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +28,7 @@ import { Container } from '../container-management/entities/container.entity';
|
|||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
||||||
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
@@ -113,6 +115,7 @@ import {
|
|||||||
eatDay,
|
eatDay,
|
||||||
} from './batch-window.util';
|
} from './batch-window.util';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
|
import { BookingJourneyService } from './booking-journey.service';
|
||||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||||
@@ -276,6 +279,7 @@ export class TrainSchedulingService {
|
|||||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
|
private readonly bookingJourneyService: BookingJourneyService,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
private readonly configService?: ConfigService,
|
private readonly configService?: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
@@ -290,15 +294,26 @@ export class TrainSchedulingService {
|
|||||||
private async completeMilestonesForScheduleBookings(
|
private async completeMilestonesForScheduleBookings(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
codes: string[],
|
codes: string[],
|
||||||
|
filter?: { originYardId?: string; destinationYardId?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.milestoneService || codes.length === 0) return;
|
if (!this.milestoneService || codes.length === 0) return;
|
||||||
try {
|
try {
|
||||||
|
const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL'];
|
||||||
|
const params: unknown[] = [scheduleId];
|
||||||
|
if (filter?.originYardId) {
|
||||||
|
params.push(filter.originYardId);
|
||||||
|
conditions.push(`b.origin_yard_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (filter?.destinationYardId) {
|
||||||
|
params.push(filter.destinationYardId);
|
||||||
|
conditions.push(`b.destination_yard_id = $${params.length}`);
|
||||||
|
}
|
||||||
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
|
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
|
||||||
`SELECT tsb.booking_id
|
`SELECT tsb.booking_id
|
||||||
FROM freight.train_schedule_bookings tsb
|
FROM freight.train_schedule_bookings tsb
|
||||||
WHERE tsb.train_schedule_id = $1
|
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||||||
AND tsb.deleted_at IS NULL`,
|
WHERE ${conditions.join(' AND ')}`,
|
||||||
[scheduleId],
|
params,
|
||||||
);
|
);
|
||||||
for (const { booking_id } of rows) {
|
for (const { booking_id } of rows) {
|
||||||
for (const code of codes) {
|
for (const code of codes) {
|
||||||
@@ -1457,6 +1472,24 @@ export class TrainSchedulingService {
|
|||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Per-booking journey fallback: bookings boarding at the TRAIN's origin
|
||||||
|
// that the operator didn't load individually are auto-loaded now — the
|
||||||
|
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
||||||
|
// operator loads them at their own yard.
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.bookings b
|
||||||
|
SET status = 'IN_TRANSIT',
|
||||||
|
loaded_at = COALESCE(b.loaded_at, $3)
|
||||||
|
FROM freight.train_schedule_bookings tsb
|
||||||
|
WHERE tsb.booking_id = b.id
|
||||||
|
AND tsb.train_schedule_id = $1
|
||||||
|
AND tsb.deleted_at IS NULL
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
AND b.origin_yard_id = $2
|
||||||
|
AND b.loaded_at IS NULL
|
||||||
|
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
|
||||||
|
[scheduleId, schedule.originStationId, now],
|
||||||
|
);
|
||||||
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||||||
await manager
|
await manager
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
@@ -1488,18 +1521,24 @@ export class TrainSchedulingService {
|
|||||||
// Dispatch closed the window — drop it from portal/GL cards right away.
|
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||||||
void this.emitWindowState(scheduleId);
|
void this.emitWindowState(scheduleId);
|
||||||
// Customer tracking: cargo is on the departing train — loading milestones
|
// Customer tracking: cargo is on the departing train — loading milestones
|
||||||
// plus the direction's "departed" handoff milestone.
|
// plus the direction's "departed" handoff milestone. Restricted to bookings
|
||||||
|
// that BOARD at the train's origin; mid-corridor boarders get their loading
|
||||||
|
// milestones from their own operator load at their own yard.
|
||||||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
void this.completeMilestonesForScheduleBookings(
|
||||||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
scheduleId,
|
||||||
// doc-trigger path no-ops it for import bookings.
|
[
|
||||||
'CARGO_ARRIVED',
|
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||||
'READY_FOR_LOADING',
|
// doc-trigger path no-ops it for import bookings.
|
||||||
'LOADED',
|
'CARGO_ARRIVED',
|
||||||
schedule.direction === 'IMPORT'
|
'READY_FOR_LOADING',
|
||||||
? 'DEPARTED_FROM_DJIBOUTI'
|
'LOADED',
|
||||||
: 'DEPARTED_TO_DJIBOUTI',
|
schedule.direction === 'IMPORT'
|
||||||
]);
|
? 'DEPARTED_FROM_DJIBOUTI'
|
||||||
|
: 'DEPARTED_TO_DJIBOUTI',
|
||||||
|
],
|
||||||
|
{ originYardId: schedule.originStationId },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
@@ -2426,18 +2465,11 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await manager.query(
|
// Per-booking journey: bookings destined for the FINAL yard that the
|
||||||
`UPDATE freight.bookings b
|
// operator didn't unload individually get their arrival stamped now as a
|
||||||
SET status = $2,
|
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
|
||||||
scheduling_status = $3
|
// their own unload (possibly already done while the train kept rolling).
|
||||||
FROM freight.train_schedule_bookings tsb
|
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
|
||||||
WHERE tsb.booking_id = b.id
|
|
||||||
AND tsb.train_schedule_id = $1
|
|
||||||
AND tsb.deleted_at IS NULL
|
|
||||||
AND b.deleted_at IS NULL
|
|
||||||
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
|
|
||||||
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Release every locomotive of the set (not just the legacy primary) and move it
|
// Release every locomotive of the set (not just the legacy primary) and move it
|
||||||
// to the destination yard where it physically arrived.
|
// to the destination yard where it physically arrived.
|
||||||
@@ -2455,12 +2487,33 @@ export class TrainSchedulingService {
|
|||||||
.getRepository(Wagon)
|
.getRepository(Wagon)
|
||||||
.findOne({ where: { id: slot.physicalWagonId } });
|
.findOne({ where: { id: slot.physicalWagonId } });
|
||||||
if (!wagon) continue;
|
if (!wagon) continue;
|
||||||
|
// A wagon that already alighted mid-route (unload released it, possibly
|
||||||
|
// re-pinned elsewhere since) is no longer this schedule's to move.
|
||||||
|
if (wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||||
|
// Dynamic consist: the wagon settles at its slot's alight yard, not
|
||||||
|
// blanket at the train's destination.
|
||||||
|
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
|
||||||
await manager.getRepository(Wagon).update(wagon.id, {
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
currentTrainScheduleId: null,
|
currentTrainScheduleId: null,
|
||||||
trainSetWagonId: null,
|
trainSetWagonId: null,
|
||||||
status: WagonStatus.Available,
|
status: WagonStatus.Available,
|
||||||
currentYardId: schedule.destinationStationId,
|
currentYardId: settleYardId,
|
||||||
});
|
});
|
||||||
|
// Ledger: the wagon rode this schedule to its settle yard.
|
||||||
|
const slotAllocations = slot.allocations ?? [];
|
||||||
|
await manager.getRepository(WagonMovement).save(
|
||||||
|
manager.getRepository(WagonMovement).create({
|
||||||
|
wagonId: wagon.id,
|
||||||
|
fromYardId: slot.boardYardId ?? schedule.originStationId,
|
||||||
|
toYardId: settleYardId,
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
bookingId: slotAllocations[0]?.bookingId ?? null,
|
||||||
|
kind: slotAllocations.length
|
||||||
|
? WagonMovementKind.Loaded
|
||||||
|
: WagonMovementKind.EmptyReposition,
|
||||||
|
occurredAt: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||||||
@@ -2482,11 +2535,15 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Customer tracking: the train reached the corridor's far end.
|
// Customer tracking: the train reached the corridor's far end. Restricted
|
||||||
|
// to bookings destined for the FINAL yard — mid-corridor bookings get their
|
||||||
|
// arrival milestone from their own operator unload at their own yard.
|
||||||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
void this.completeMilestonesForScheduleBookings(
|
||||||
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
|
scheduleId,
|
||||||
]);
|
[schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
|
||||||
|
{ destinationYardId: schedule.destinationStationId },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = await this.getTrainScheduleById(scheduleId);
|
const detail = await this.getTrainScheduleById(scheduleId);
|
||||||
@@ -2635,17 +2692,26 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bookings.some((b) => {
|
await (async () => {
|
||||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
// Corridor-aware: a booking belongs on this train when its origin and
|
||||||
return false;
|
// destination lie on the schedule's stop list in order — sub-corridor
|
||||||
|
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
|
||||||
|
let stops = [dto.originStationId, dto.destinationStationId];
|
||||||
|
if (targetScheduleId) {
|
||||||
|
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||||
|
if (target) stops = await this.stopYardsForSchedule(target);
|
||||||
}
|
}
|
||||||
return (
|
return bookings.some((b) => {
|
||||||
b.originYardId !== dto.originStationId ||
|
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||||
b.destinationYardId !== dto.destinationStationId
|
return false;
|
||||||
);
|
}
|
||||||
})
|
const fromIdx = stops.indexOf(b.originYardId);
|
||||||
|
const toIdx = stops.indexOf(b.destinationYardId);
|
||||||
|
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||||
|
});
|
||||||
|
})()
|
||||||
) {
|
) {
|
||||||
violations.push('Selected bookings must share the same origin and destination as the schedule');
|
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forceAssign) {
|
if (!forceAssign) {
|
||||||
@@ -2695,7 +2761,37 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const originYardId = dto.originStationId;
|
const originYardId = dto.originStationId;
|
||||||
const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId);
|
// Dynamic consist: a slot's physical wagon may ride from the train's origin
|
||||||
|
// OR already sit at the booking's own boarding yard and attach there — so
|
||||||
|
// the usable fleet is the union across the origin and every boarding yard.
|
||||||
|
const boardYardIds = [
|
||||||
|
...new Set(
|
||||||
|
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const fleetCountsByYard = await Promise.all(
|
||||||
|
boardYardIds.map((yardId) =>
|
||||||
|
this.countFleetAvailability(yardId, targetScheduleId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const mergedFleet = new Map<string, { code: string; available: number }>();
|
||||||
|
for (const rows of fleetCountsByYard) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = mergedFleet.get(row.wagonTypeId) ?? {
|
||||||
|
code: row.wagonTypeCode,
|
||||||
|
available: 0,
|
||||||
|
};
|
||||||
|
existing.available += row.available;
|
||||||
|
mergedFleet.set(row.wagonTypeId, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fleetCounts = [...mergedFleet.entries()].map(
|
||||||
|
([wagonTypeId, value]) => ({
|
||||||
|
wagonTypeId,
|
||||||
|
wagonTypeCode: value.code,
|
||||||
|
available: value.available,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
|
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
|
||||||
fleetAvailability = computeFleetAvailability(
|
fleetAvailability = computeFleetAvailability(
|
||||||
demandPlan,
|
demandPlan,
|
||||||
@@ -2720,6 +2816,12 @@ export class TrainSchedulingService {
|
|||||||
containerWagonType,
|
containerWagonType,
|
||||||
bulkWagonType,
|
bulkWagonType,
|
||||||
});
|
});
|
||||||
|
this.stampSlotLegs(
|
||||||
|
wagonPlan,
|
||||||
|
fittingBookings,
|
||||||
|
dto.originStationId,
|
||||||
|
dto.destinationStationId,
|
||||||
|
);
|
||||||
|
|
||||||
violations.push(
|
violations.push(
|
||||||
...(await this.validatePhysicalFleetForPlan(
|
...(await this.validatePhysicalFleetForPlan(
|
||||||
@@ -3047,6 +3149,7 @@ export class TrainSchedulingService {
|
|||||||
wagonTypeId: slot.wagonTypeId,
|
wagonTypeId: slot.wagonTypeId,
|
||||||
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
|
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
|
||||||
trainSetWagonId: slot.id,
|
trainSetWagonId: slot.id,
|
||||||
|
boardYardId: slot.boardYardId ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const unpinnable = this.findUnpinnableWagonSlots(
|
const unpinnable = this.findUnpinnableWagonSlots(
|
||||||
@@ -3100,6 +3203,7 @@ export class TrainSchedulingService {
|
|||||||
sequenceNo: slot.sequenceNo,
|
sequenceNo: slot.sequenceNo,
|
||||||
wagonTypeId: slot.wagonTypeId,
|
wagonTypeId: slot.wagonTypeId,
|
||||||
wagonTypeCode: slot.wagonTypeCode,
|
wagonTypeCode: slot.wagonTypeCode,
|
||||||
|
boardYardId: slot.boardYardId ?? null,
|
||||||
})),
|
})),
|
||||||
wagons,
|
wagons,
|
||||||
targetScheduleId,
|
targetScheduleId,
|
||||||
@@ -3108,7 +3212,12 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private findUnpinnableWagonSlots(
|
private findUnpinnableWagonSlots(
|
||||||
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
slots: Array<{
|
||||||
|
sequenceNo: number;
|
||||||
|
wagonTypeId: string;
|
||||||
|
wagonTypeCode: string;
|
||||||
|
boardYardId?: string | null;
|
||||||
|
}>,
|
||||||
wagons: Wagon[],
|
wagons: Wagon[],
|
||||||
scheduleId: string | undefined,
|
scheduleId: string | undefined,
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
@@ -3136,22 +3245,35 @@ export class TrainSchedulingService {
|
|||||||
return violations;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic consist: a slot's wagon may either ride from the train's origin
|
||||||
|
* yard (attaching there, possibly empty until the slot's board yard) or
|
||||||
|
* already sit AT the slot's board yard and hook on when the train arrives.
|
||||||
|
*/
|
||||||
private pickPhysicalWagonForSlot(
|
private pickPhysicalWagonForSlot(
|
||||||
slot: { wagonTypeId: string },
|
slot: { wagonTypeId: string; boardYardId?: string | null },
|
||||||
wagons: Wagon[],
|
wagons: Wagon[],
|
||||||
scheduleId: string | undefined,
|
scheduleId: string | undefined,
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
assignedPhysicalIds: Set<string>,
|
assignedPhysicalIds: Set<string>,
|
||||||
): Wagon | undefined {
|
): Wagon | undefined {
|
||||||
return wagons.find((wagon) => {
|
const usable = (wagon: Wagon): boolean => {
|
||||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||||
const pinnedOnSchedule = scheduleId
|
const pinnedOnSchedule = scheduleId
|
||||||
? wagon.currentTrainScheduleId === scheduleId
|
? wagon.currentTrainScheduleId === scheduleId
|
||||||
: false;
|
: false;
|
||||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
|
||||||
return wagon.currentYardId === originYardId;
|
};
|
||||||
});
|
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
|
||||||
|
// fall back to one riding from the train's origin.
|
||||||
|
if (slot.boardYardId) {
|
||||||
|
const atBoardYard = wagons.find(
|
||||||
|
(w) => usable(w) && w.currentYardId === slot.boardYardId,
|
||||||
|
);
|
||||||
|
if (atBoardYard) return atBoardYard;
|
||||||
|
}
|
||||||
|
return wagons.find((w) => usable(w) && w.currentYardId === originYardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private positiveNumber(value: number | undefined, fallback: number): number {
|
private positiveNumber(value: number | undefined, fallback: number): number {
|
||||||
@@ -3303,6 +3425,42 @@ export class TrainSchedulingService {
|
|||||||
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
|
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp each plan slot with the leg it occupies (dynamic consist): the
|
||||||
|
* boarding/alighting yards of the bookings it carries. Null means the
|
||||||
|
* schedule's own endpoint (whole-route slot, legacy behavior). A slot
|
||||||
|
* carrying bookings with mixed corridors stays whole-route (conservative).
|
||||||
|
*/
|
||||||
|
private stampSlotLegs(
|
||||||
|
wagonPlan: WagonPlanSlot[],
|
||||||
|
bookings: Booking[],
|
||||||
|
scheduleOriginYardId: string,
|
||||||
|
scheduleDestinationYardId: string,
|
||||||
|
): void {
|
||||||
|
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||||||
|
for (const slot of wagonPlan) {
|
||||||
|
const slotBookings = [
|
||||||
|
...new Set(slot.allocations.map((a) => a.bookingId)),
|
||||||
|
]
|
||||||
|
.map((id) => bookingById.get(id))
|
||||||
|
.filter((b): b is Booking => Boolean(b));
|
||||||
|
if (!slotBookings.length) continue;
|
||||||
|
const [first] = slotBookings;
|
||||||
|
const sameCorridor = slotBookings.every(
|
||||||
|
(b) =>
|
||||||
|
b.originYardId === first.originYardId &&
|
||||||
|
b.destinationYardId === first.destinationYardId,
|
||||||
|
);
|
||||||
|
if (!sameCorridor) continue;
|
||||||
|
slot.boardYardId =
|
||||||
|
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
|
||||||
|
slot.alightYardId =
|
||||||
|
first.destinationYardId === scheduleDestinationYardId
|
||||||
|
? null
|
||||||
|
: first.destinationYardId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async persistTrainSetWagons(
|
private async persistTrainSetWagons(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
trainSetId: string,
|
trainSetId: string,
|
||||||
@@ -3318,6 +3476,8 @@ export class TrainSchedulingService {
|
|||||||
lengthMeters: slot.lengthMeters,
|
lengthMeters: slot.lengthMeters,
|
||||||
assignedWeightTons: slot.assignedWeightTons,
|
assignedWeightTons: slot.assignedWeightTons,
|
||||||
status: 'PLANNED',
|
status: 'PLANNED',
|
||||||
|
boardYardId: slot.boardYardId ?? null,
|
||||||
|
alightYardId: slot.alightYardId ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return manager.getRepository(TrainSetWagon).save(wagons);
|
return manager.getRepository(TrainSetWagon).save(wagons);
|
||||||
@@ -4012,26 +4172,44 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
// How many wagons of that type the cargo needs.
|
// How many wagons of that type the cargo needs.
|
||||||
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
|
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
|
||||||
|
void slotsNeeded; // TEMP: unused while the wagon-availability filter is off.
|
||||||
|
|
||||||
// AVAILABLE wagons of the required type, counted once per origin yard.
|
// TEMP (per request): wagon-availability filtering is DISABLED. A day is now
|
||||||
const availableByYard = new Map<string, number>();
|
// offered whenever a bookable schedule that day has remaining train capacity
|
||||||
const availableAt = async (yardId: string): Promise<number> => {
|
// — regardless of whether matching wagons are actually available at the
|
||||||
const cached = availableByYard.get(yardId);
|
// origin / boarding yard. This surfaces days even when no wagon is on hand.
|
||||||
if (cached !== undefined) return cached;
|
// Restore the block below to bring back the "enough matching wagons" gate.
|
||||||
const counts = await this.countFleetAvailability(yardId);
|
//
|
||||||
const n =
|
// // AVAILABLE wagons of the required type, counted once per origin yard.
|
||||||
counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
// const availableByYard = new Map<string, number>();
|
||||||
availableByYard.set(yardId, n);
|
// const availableAt = async (yardId: string): Promise<number> => {
|
||||||
return n;
|
// const cached = availableByYard.get(yardId);
|
||||||
};
|
// if (cached !== undefined) return cached;
|
||||||
|
// const counts = await this.countFleetAvailability(yardId);
|
||||||
|
// const n =
|
||||||
|
// counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||||
|
// availableByYard.set(yardId, n);
|
||||||
|
// return n;
|
||||||
|
// };
|
||||||
|
|
||||||
const days = new Set<string>();
|
const days = new Set<string>();
|
||||||
for (const s of schedules) {
|
for (const s of schedules) {
|
||||||
const hasCapacity =
|
const hasCapacity =
|
||||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||||
if (!hasCapacity) continue;
|
if (!hasCapacity) continue;
|
||||||
const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
// TEMP (per request): wagon-availability check commented out — see note
|
||||||
if (!enoughWagons) continue;
|
// above. Dynamic consist: wagons may ride from the train's origin OR
|
||||||
|
// already sit at the booking's own boarding yard and attach when the train
|
||||||
|
// arrives — either pool can serve a sub-corridor booking.
|
||||||
|
// let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||||
|
// if (
|
||||||
|
// !enoughWagons &&
|
||||||
|
// input.originYardId &&
|
||||||
|
// input.originYardId !== s.originStationId
|
||||||
|
// ) {
|
||||||
|
// enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded;
|
||||||
|
// }
|
||||||
|
// if (!enoughWagons) continue;
|
||||||
if (s.scheduledDepartureDate)
|
if (s.scheduledDepartureDate)
|
||||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||||
}
|
}
|
||||||
@@ -4063,6 +4241,33 @@ export class TrainSchedulingService {
|
|||||||
return Math.max(1, Math.ceil(teu / 2));
|
return Math.max(1, Math.ceil(teu / 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||||
|
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||||
|
* has no route milestones. Shared by corridor (sub-leg) validation everywhere.
|
||||||
|
*/
|
||||||
|
async stopYardsForSchedule(schedule: TrainSchedule): Promise<string[]> {
|
||||||
|
let milestoneYards: string[] = [];
|
||||||
|
if (schedule.route?.milestones?.length) {
|
||||||
|
milestoneYards = [...schedule.route.milestones]
|
||||||
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
|
.map((m) => m.yardId);
|
||||||
|
} else if (schedule.routeId) {
|
||||||
|
const milestones = await this.dataSource
|
||||||
|
.getRepository(RouteMilestone)
|
||||||
|
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||||
|
milestoneYards = milestones.map((m) => m.yardId);
|
||||||
|
}
|
||||||
|
const raw = milestoneYards.length >= 2
|
||||||
|
? milestoneYards
|
||||||
|
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
|
||||||
|
const unique: string[] = [];
|
||||||
|
for (const yardId of raw) {
|
||||||
|
if (yardId && !unique.includes(yardId)) unique.push(yardId);
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||||
async existsOpenScheduleOnRouteDay(
|
async existsOpenScheduleOnRouteDay(
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export type WagonPlanSlot = {
|
|||||||
assignedWeightTons: number;
|
assignedWeightTons: number;
|
||||||
allocations: WagonAllocationRecord[];
|
allocations: WagonAllocationRecord[];
|
||||||
slotLoadType?: SlotLoadType;
|
slotLoadType?: SlotLoadType;
|
||||||
|
/**
|
||||||
|
* Leg occupancy for sub-corridor bookings (dynamic consist): the slot boards
|
||||||
|
* at boardYardId and alights at alightYardId. Null = the schedule's own
|
||||||
|
* endpoint (whole-route slot, legacy behavior).
|
||||||
|
*/
|
||||||
|
boardYardId?: string | null;
|
||||||
|
alightYardId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContainerUnitRow = {
|
export type ContainerUnitRow = {
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ export class TrainSetWagon extends BaseEntity {
|
|||||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
|
||||||
|
// ── Leg occupancy (segment corridor bookings) ──────────────────────────────
|
||||||
|
// A slot may occupy only part of the route: it boards (attaches/loads) at
|
||||||
|
// board_yard_id and alights (unloads/detaches) at alight_yard_id. NULL on both
|
||||||
|
// means the slot rides the whole route (legacy full-route bookings). Slots
|
||||||
|
// whose legs don't overlap coexist without consuming each other's capacity.
|
||||||
|
@Column({ name: 'board_yard_id', type: 'uuid', nullable: true })
|
||||||
|
boardYardId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'alight_yard_id', type: 'uuid', nullable: true })
|
||||||
|
alightYardId?: string | null;
|
||||||
|
|
||||||
@OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
|
@OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
|
||||||
allocations?: WagonBookingAllocation[];
|
allocations?: WagonBookingAllocation[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { WagonMovementKind } from '@edr/types';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { Wagon } from './wagon.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ledger of every physical wagon relocation between yards — one row per move.
|
||||||
|
* Written when a wagon carries a booking's leg (LOADED), rides a train empty to
|
||||||
|
* reposition (EMPTY_REPOSITION), or staff manually correct its yard (MANUAL).
|
||||||
|
* `wagons.current_yard_id` is the derived "where is it now"; this table is the
|
||||||
|
* auditable history of how it got there and by whom.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'wagon_movements' })
|
||||||
|
@Index(['wagonId', 'occurredAt'])
|
||||||
|
export class WagonMovement extends BaseEntity {
|
||||||
|
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||||
|
wagonId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Wagon, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'wagon_id' })
|
||||||
|
wagon?: Wagon;
|
||||||
|
|
||||||
|
/** Null when the prior location is unknown (e.g. first manual registration). */
|
||||||
|
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
|
||||||
|
fromYardId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'from_yard_id' })
|
||||||
|
fromYard?: Yard | null;
|
||||||
|
|
||||||
|
@Column({ name: 'to_yard_id', type: 'uuid' })
|
||||||
|
toYardId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard)
|
||||||
|
@JoinColumn({ name: 'to_yard_id' })
|
||||||
|
toYard?: Yard | null;
|
||||||
|
|
||||||
|
/** Set when the move happened by riding a scheduled train (LOADED / EMPTY_REPOSITION). */
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
|
trainScheduleId?: string | null;
|
||||||
|
|
||||||
|
/** Set when the move carried a specific booking's cargo (kind LOADED). */
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||||
|
bookingId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'kind', type: 'varchar', length: 30 })
|
||||||
|
kind!: WagonMovementKind;
|
||||||
|
|
||||||
|
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
movedByUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||||
|
occurredAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
@@ -43,6 +43,14 @@ export class WagonsController {
|
|||||||
return this.wagonsService.findById(id);
|
return this.wagonsService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/movements')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
|
||||||
|
})
|
||||||
|
listMovements(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.wagonsService.listMovements(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a wagon' })
|
@ApiOperation({ summary: 'Update a wagon' })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { WagonStatus } from '@edr/types';
|
import { WagonMovementKind, WagonStatus } from '@edr/types';
|
||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
||||||
@@ -8,6 +8,7 @@ import { UpdateWagonDto } from './dto/update-wagon.dto';
|
|||||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||||
import { Wagon } from './entities/wagon.entity';
|
import { Wagon } from './entities/wagon.entity';
|
||||||
|
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Train } from '../trains/entities/train.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -74,8 +75,9 @@ export class WagonsService {
|
|||||||
return wagon;
|
return wagon;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
|
async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise<Wagon> {
|
||||||
const wagon = await this.findById(id);
|
const wagon = await this.findById(id);
|
||||||
|
const previousYardId = wagon.currentYardId ?? null;
|
||||||
Object.assign(wagon, dto);
|
Object.assign(wagon, dto);
|
||||||
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
||||||
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
|
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
|
||||||
@@ -85,11 +87,40 @@ export class WagonsService {
|
|||||||
wagon.currentYard = null;
|
wagon.currentYard = null;
|
||||||
}
|
}
|
||||||
await this.wagonRepo.save(wagon);
|
await this.wagonRepo.save(wagon);
|
||||||
|
// Staff manually relocated the wagon — write the movement ledger row so the
|
||||||
|
// wagon's yard history stays auditable (who moved it, from where, when).
|
||||||
|
if (
|
||||||
|
dto.currentYardId !== undefined &&
|
||||||
|
dto.currentYardId !== null &&
|
||||||
|
dto.currentYardId !== previousYardId
|
||||||
|
) {
|
||||||
|
const movementRepo = this.dataSource.getRepository(WagonMovement);
|
||||||
|
await movementRepo.save(
|
||||||
|
movementRepo.create({
|
||||||
|
wagonId: id,
|
||||||
|
fromYardId: previousYardId,
|
||||||
|
toYardId: dto.currentYardId,
|
||||||
|
kind: WagonMovementKind.Manual,
|
||||||
|
movedByUserId: userId ?? null,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
// Re-read with the relation so the response reflects the new yard label
|
// Re-read with the relation so the response reflects the new yard label
|
||||||
// instead of the stale relation object loaded before the assign.
|
// instead of the stale relation object loaded before the assign.
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
|
||||||
|
async listMovements(wagonId: string): Promise<WagonMovement[]> {
|
||||||
|
await this.findById(wagonId); // 404 on unknown wagon
|
||||||
|
return this.dataSource.getRepository(WagonMovement).find({
|
||||||
|
where: { wagonId },
|
||||||
|
relations: { fromYard: true, toYard: true },
|
||||||
|
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const wagon = await this.findById(id);
|
const wagon = await this.findById(id);
|
||||||
await this.wagonRepo.remove(wagon);
|
await this.wagonRepo.remove(wagon);
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ export class SchedulingReadFacade {
|
|||||||
'LOADED',
|
'LOADED',
|
||||||
'DISPATCHED',
|
'DISPATCHED',
|
||||||
'IN_TRANSIT',
|
'IN_TRANSIT',
|
||||||
|
'ARRIVED',
|
||||||
'ARRIVED_AT_DJIBOUTI',
|
'ARRIVED_AT_DJIBOUTI',
|
||||||
'ARRIVED_AT_PORT',
|
'ARRIVED_AT_PORT',
|
||||||
'ARRIVED_AT_DESTINATION',
|
'ARRIVED_AT_DESTINATION',
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ export class WarehouseInventoryService {
|
|||||||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||||
|
|
||||||
/** Bookings whose goods have arrived and may be unloaded into the warehouse. */
|
/** Bookings whose goods have arrived and may be unloaded into the warehouse. */
|
||||||
private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT'];
|
private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED'];
|
||||||
|
|
||||||
/** Arrived bookings + their current inventory/inspection state (queue view). */
|
/** Arrived bookings + their current inventory/inspection state (queue view). */
|
||||||
async arrivalQueue(): Promise<ArrivalQueueItem[]> {
|
async arrivalQueue(): Promise<ArrivalQueueItem[]> {
|
||||||
|
|||||||
171
apps/edr-freight-api/src/seed/freight-positions.seeder.ts
Normal file
171
apps/edr-freight-api/src/seed/freight-positions.seeder.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Organization,
|
||||||
|
Permission,
|
||||||
|
Position,
|
||||||
|
PositionPermission,
|
||||||
|
Unit,
|
||||||
|
} from '@tria-plc/iamapi-common';
|
||||||
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
|
|
||||||
|
import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed';
|
||||||
|
|
||||||
|
const SEED_FLAG = 'SEED_EDR_ORG';
|
||||||
|
const EDR_ORG_KEY = 'edr_freight';
|
||||||
|
const EDR_UNIT_KEY = 'edr_freight_app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds the operational freight positions (CEO, Chief, Director, Marketer,
|
||||||
|
* Operation, Ethiopian GL, Djibouti GL) as Position + PositionPermission rows
|
||||||
|
* on the `edr_freight_app` unit. Positions-as-roles: users get their freight
|
||||||
|
* access by being assigned to a Position (via EmployeePosition), and the
|
||||||
|
* position's PositionPermission grants come from EDR_FREIGHT_POSITIONS.
|
||||||
|
*
|
||||||
|
* Gated behind the same SEED_EDR_ORG flag as EdrOrgSeeder and depends on the
|
||||||
|
* org/unit/permission catalog it seeds, so it must run AFTER EdrOrgSeeder.
|
||||||
|
* Idempotent: positions upsert by (key, unitId); grants insert only the
|
||||||
|
* permission ids a position is still missing.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FreightPositionsSeeder {
|
||||||
|
private readonly logger = new Logger(FreightPositionsSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||||
|
this.logger.log(
|
||||||
|
`Skipping freight positions seed because ${SEED_FLAG} is not enabled`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const organization = await manager.getRepository(Organization).findOne({
|
||||||
|
where: { key: EDR_ORG_KEY },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new Error(`missing_organization:${EDR_ORG_KEY}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = await manager.getRepository(Unit).findOne({
|
||||||
|
where: { key: EDR_UNIT_KEY, organizationId: organization.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new Error(`missing_unit:${EDR_UNIT_KEY}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionKeyToId = await this.loadPermissionIds(manager);
|
||||||
|
|
||||||
|
for (const seed of EDR_FREIGHT_POSITIONS) {
|
||||||
|
const positionId = await this.ensurePosition(
|
||||||
|
manager,
|
||||||
|
seed,
|
||||||
|
unit.id as string,
|
||||||
|
organization.id as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.ensurePositionPermissions(
|
||||||
|
manager,
|
||||||
|
positionId,
|
||||||
|
seed,
|
||||||
|
permissionKeyToId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Ensured ${EDR_FREIGHT_POSITIONS.length} freight positions on unit '${EDR_UNIT_KEY}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve every permission key referenced by any position to its id. */
|
||||||
|
private async loadPermissionIds(
|
||||||
|
manager: EntityManager,
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const keys = [
|
||||||
|
...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const permissions = await manager.getRepository(Permission).find({
|
||||||
|
where: { key: In(keys) },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const map = new Map(permissions.map((p) => [p.key, p.id as string]));
|
||||||
|
|
||||||
|
const missing = keys.filter((key) => !map.has(key));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`missing_permissions:${missing.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePosition(
|
||||||
|
manager: EntityManager,
|
||||||
|
seed: (typeof EDR_FREIGHT_POSITIONS)[number],
|
||||||
|
unitId: string,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const positionRepository = manager.getRepository(Position);
|
||||||
|
|
||||||
|
const existing = await positionRepository.findOne({
|
||||||
|
where: { key: seed.key, unitId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing.id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await positionRepository.insert({
|
||||||
|
key: seed.key,
|
||||||
|
name: { ...seed.name },
|
||||||
|
rank: seed.rank,
|
||||||
|
unitId,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Seeded freight position '${seed.key}'`);
|
||||||
|
|
||||||
|
return inserted.identifiers[0]?.id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePositionPermissions(
|
||||||
|
manager: EntityManager,
|
||||||
|
positionId: string,
|
||||||
|
seed: (typeof EDR_FREIGHT_POSITIONS)[number],
|
||||||
|
permissionKeyToId: Map<string, string>,
|
||||||
|
) {
|
||||||
|
const positionPermissionRepository =
|
||||||
|
manager.getRepository(PositionPermission);
|
||||||
|
|
||||||
|
const existing = await positionPermissionRepository.find({
|
||||||
|
where: { positionId },
|
||||||
|
select: { permissionId: true },
|
||||||
|
});
|
||||||
|
const existingPermissionIds = new Set(
|
||||||
|
existing.map((row) => row.permissionId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowsToInsert = seed.permissionKeys
|
||||||
|
.map((key) => permissionKeyToId.get(key) as string)
|
||||||
|
.filter((permissionId) => !existingPermissionIds.has(permissionId))
|
||||||
|
.map((permissionId) => ({ positionId, permissionId }));
|
||||||
|
|
||||||
|
if (rowsToInsert.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await positionPermissionRepository.insert(rowsToInsert);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Granted ${rowsToInsert.length} permissions to position '${seed.key}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { DataSource } from 'typeorm';
|
|||||||
|
|
||||||
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
|
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
|
||||||
const EDR_ORG_KEY = 'edr_freight';
|
const EDR_ORG_KEY = 'edr_freight';
|
||||||
const EDR_UNIT_KEY = 'edr_freight_hq';
|
const EDR_UNIT_KEY = 'edr_freight_app';
|
||||||
|
|
||||||
// roleKey is kept only for backwards compatibility with existing UserRole rows;
|
// roleKey is kept only for backwards compatibility with existing UserRole rows;
|
||||||
// access is granted via the assigned position (positionKey) + PositionPermission.
|
// access is granted via the assigned position (positionKey) + PositionPermission.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const statusColorMap: Record<string, string> = {
|
|||||||
EXPIRED: "red",
|
EXPIRED: "red",
|
||||||
PAID: "edr-green",
|
PAID: "edr-green",
|
||||||
IN_TRANSIT: "cyan",
|
IN_TRANSIT: "cyan",
|
||||||
|
ARRIVED: "teal",
|
||||||
COMPLETED: "indigo",
|
COMPLETED: "indigo",
|
||||||
REJECTED: "red",
|
REJECTED: "red",
|
||||||
CANCELLED: "red",
|
CANCELLED: "red",
|
||||||
|
|||||||
@@ -630,6 +630,20 @@ function DocReviewCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{hasFile && (
|
||||||
|
<Tooltip label="Download">
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(doc.file!.id, true)}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Download size={13} />}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -727,9 +727,9 @@ function ImportT1UploadStep({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const departed = Boolean(t1.trainDepartedAt);
|
// Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia
|
||||||
const canUpload =
|
// closes/accepts the T1.
|
||||||
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
|
const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -769,10 +769,6 @@ function ImportT1UploadStep({
|
|||||||
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
|
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
|
||||||
doneLabel=""
|
doneLabel=""
|
||||||
/>
|
/>
|
||||||
) : departed ? (
|
|
||||||
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
|
|
||||||
The train has departed — T1 documents are locked and can no longer be changed.
|
|
||||||
</Alert>
|
|
||||||
) : uploaded.length === 0 && !canUpload ? (
|
) : uploaded.length === 0 && !canUpload ? (
|
||||||
<StepStatus
|
<StepStatus
|
||||||
done={false}
|
done={false}
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
|||||||
APPROVED: "cyan",
|
APPROVED: "cyan",
|
||||||
PAID: "edr-green",
|
PAID: "edr-green",
|
||||||
IN_TRANSIT: "blue",
|
IN_TRANSIT: "blue",
|
||||||
|
ARRIVED: "teal",
|
||||||
COMPLETED: "indigo",
|
COMPLETED: "indigo",
|
||||||
REJECTED: "red",
|
REJECTED: "red",
|
||||||
CANCELLED: "red",
|
CANCELLED: "red",
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ const FleetRecordActions = ({
|
|||||||
const isVehicle = config.slug === "vehicles";
|
const isVehicle = config.slug === "vehicles";
|
||||||
const showHistory =
|
const showHistory =
|
||||||
Boolean(onHistory) &&
|
Boolean(onHistory) &&
|
||||||
(config.slug === "drivers" || config.slug === "vehicles");
|
(config.slug === "drivers" ||
|
||||||
|
config.slug === "vehicles" ||
|
||||||
|
config.slug === "wagons");
|
||||||
|
|
||||||
const handleDetail = () => {
|
const handleDetail = () => {
|
||||||
if (!config.detailPath || !("id" in record)) return;
|
if (!config.detailPath || !("id" in record)) return;
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||||
|
import type { WagonMovementRecord } from "@/services/wagon.service";
|
||||||
|
|
||||||
|
export interface WagonMovementHistoryModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
record: FleetRecord | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Chip style per wagon_movements ledger kind. */
|
||||||
|
const KIND_META: Record<string, { label: string; color: string; icon: ReactNode }> = {
|
||||||
|
LOADED: {
|
||||||
|
label: "Loaded leg",
|
||||||
|
color: "edr-green",
|
||||||
|
icon: <PackageCheck size={14} />,
|
||||||
|
},
|
||||||
|
EMPTY_REPOSITION: {
|
||||||
|
label: "Empty reposition",
|
||||||
|
color: "blue",
|
||||||
|
icon: <TrainFront size={14} />,
|
||||||
|
},
|
||||||
|
MANUAL: {
|
||||||
|
label: "Manual move",
|
||||||
|
color: "orange",
|
||||||
|
icon: <Wrench size={14} />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const yardLabel = (
|
||||||
|
yard: { label?: string; code?: string } | null | undefined,
|
||||||
|
yardId: string | null,
|
||||||
|
) => yard?.label ?? yard?.code ?? yardId ?? "Unknown";
|
||||||
|
|
||||||
|
const fmt = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Movement ledger for one wagon: every relocation between yards — booking legs,
|
||||||
|
* empty reposition rides, and manual staff corrections — newest first.
|
||||||
|
*/
|
||||||
|
const WagonMovementHistoryModal = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
record,
|
||||||
|
}: WagonMovementHistoryModalProps) => {
|
||||||
|
const r = asObj(record);
|
||||||
|
const id = r.id ? String(r.id) : "";
|
||||||
|
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery(
|
||||||
|
api.wagons.movements.queryOptions({
|
||||||
|
input: { id },
|
||||||
|
enabled: opened && Boolean(id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const movements: WagonMovementRecord[] = data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={<Text fw={600}>{`Wagon history — ${wagonNumber}`.trim()}</Text>}
|
||||||
|
radius="lg"
|
||||||
|
size="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
) : movements.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||||
|
No movements recorded yet. Every yard-to-yard move appears here — a
|
||||||
|
booking's loaded leg, an empty reposition ride, or a manual correction.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Timeline active={movements.length} bulletSize={24} lineWidth={2}>
|
||||||
|
{movements.map((movement) => {
|
||||||
|
const meta = KIND_META[movement.kind] ?? {
|
||||||
|
label: movement.kind,
|
||||||
|
color: "gray",
|
||||||
|
icon: <TrainFront size={14} />,
|
||||||
|
};
|
||||||
|
const from = yardLabel(movement.fromYard, movement.fromYardId);
|
||||||
|
const to = yardLabel(movement.toYard, movement.toYardId);
|
||||||
|
return (
|
||||||
|
<Timeline.Item
|
||||||
|
key={movement.id}
|
||||||
|
bullet={meta.icon}
|
||||||
|
title={
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{from}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={13} />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{to}
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" variant="light" color={meta.color}>
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{movement.note && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{movement.note}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="xs" mt={4} c="dimmed">
|
||||||
|
{fmt(movement.occurredAt)}
|
||||||
|
</Text>
|
||||||
|
</Timeline.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WagonMovementHistoryModal;
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
|
import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling";
|
||||||
|
|
||||||
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
|
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
if (Array.isArray(message)) return message.join("; ");
|
||||||
|
return message || (error as Error)?.message || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRECTION_COLORS: Record<string, string> = {
|
||||||
|
IMPORT: "blue",
|
||||||
|
EXPORT: "teal",
|
||||||
|
DOMESTIC: "violet",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||||
|
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||||
|
|
||||||
|
function DirectionChip({ direction }: { direction: string }) {
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||||
|
{DIRECTION_LABELS[direction] ?? direction}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingCell({ row }: { row: YardWorkBookingRow }) {
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.reference ?? row.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
{row.isGovernment && (
|
||||||
|
<Badge size="xs" variant="light" color="grape">
|
||||||
|
GOV
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkTable({
|
||||||
|
rows,
|
||||||
|
side,
|
||||||
|
trainHere,
|
||||||
|
onLoad,
|
||||||
|
onUnload,
|
||||||
|
pendingBookingId,
|
||||||
|
}: {
|
||||||
|
rows: YardWorkBookingRow[];
|
||||||
|
side: "load" | "unload";
|
||||||
|
trainHere: boolean;
|
||||||
|
onLoad: (bookingId: string) => void;
|
||||||
|
onUnload: (bookingId: string) => void;
|
||||||
|
pendingBookingId: string | null;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{side === "load" ? "No bookings board here." : "No bookings alight here."}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={720}>
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Direction</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>{side === "load" ? "Loaded" : "Arrived"}</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const timestamp = side === "load" ? row.loadedAt : row.arrivedAt;
|
||||||
|
const canAct = side === "load" ? row.canLoad : row.canUnload;
|
||||||
|
return (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<BookingCell row={row} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.customer}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<DirectionChip direction={row.tradeDirection} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<BookingStatusBadge status={row.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{timestamp ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{fmtDate(timestamp)}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end">
|
||||||
|
{side === "load" ? (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
trainHere
|
||||||
|
? "Confirm cargo loaded at this yard"
|
||||||
|
: "Train must be at this yard"
|
||||||
|
}
|
||||||
|
disabled={!canAct && Boolean(row.loadedAt)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<PackageCheck size={13} />}
|
||||||
|
disabled={!canAct || !trainHere}
|
||||||
|
loading={pendingBookingId === row.id}
|
||||||
|
onClick={() => onLoad(row.id)}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
trainHere
|
||||||
|
? "Confirm cargo unloaded at this yard"
|
||||||
|
: "Train must be at this yard"
|
||||||
|
}
|
||||||
|
disabled={!canAct && Boolean(row.arrivedAt)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<PackageOpen size={13} />}
|
||||||
|
disabled={!canAct || !trainHere}
|
||||||
|
loading={pendingBookingId === row.id}
|
||||||
|
onClick={() => onUnload(row.id)}
|
||||||
|
>
|
||||||
|
Unload
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-yard load/unload worklist for one schedule — every trade direction. Each
|
||||||
|
* booking boards at its origin yard and alights at its destination yard; the
|
||||||
|
* operator confirms both while the train's last recorded checkpoint is at that
|
||||||
|
* yard (the server validates the position). Unloading stamps the booking's own
|
||||||
|
* arrival — ARRIVED for import/export, COMPLETED for intercity.
|
||||||
|
*/
|
||||||
|
export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const yardWorkQuery = useQuery(
|
||||||
|
api.trainScheduling.yardWork.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useMutation(
|
||||||
|
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
void invalidate();
|
||||||
|
toast({ title: "Cargo loaded" });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Load failed",
|
||||||
|
description: parseError(err, "Could not confirm loading"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unload = useMutation(
|
||||||
|
api.trainScheduling.unloadScheduleBooking.mutationOptions({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
void invalidate();
|
||||||
|
toast({
|
||||||
|
title:
|
||||||
|
result.status === "COMPLETED"
|
||||||
|
? "Cargo unloaded — booking completed"
|
||||||
|
: "Cargo unloaded — booking arrived",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Unload failed",
|
||||||
|
description: parseError(err, "Could not confirm unloading"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = yardWorkQuery.data;
|
||||||
|
const yards: YardWorkYard[] = data?.yards ?? [];
|
||||||
|
const trainAtYardId = data?.trainAtYardId ?? null;
|
||||||
|
const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null;
|
||||||
|
const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<MapPin size={18} />
|
||||||
|
<Text fw={700}>Yard load / unload</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{yardWorkQuery.isLoading ? (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading yard worklists…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : yardWorkQuery.isError ? (
|
||||||
|
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||||
|
{parseError(yardWorkQuery.error, "Could not load the yard worklist")}
|
||||||
|
</Alert>
|
||||||
|
) : yards.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No bookings are assigned to this schedule yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
What boards and alights at each stop. Confirm loading at a booking's
|
||||||
|
origin and unloading at its destination while the train is at that
|
||||||
|
yard — unloading stamps the booking's own arrival, even before the
|
||||||
|
train's final stop.
|
||||||
|
</Text>
|
||||||
|
{yards.map((yard, index) => {
|
||||||
|
const trainHere = trainAtYardId === yard.yardId;
|
||||||
|
return (
|
||||||
|
<Stack key={yard.yardId} gap="sm">
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={600}>{yard.yard}</Text>
|
||||||
|
{trainHere && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<TrainFront size={12} />}
|
||||||
|
>
|
||||||
|
Train here
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" fw={600} c="dimmed">
|
||||||
|
Board here
|
||||||
|
</Text>
|
||||||
|
<WorkTable
|
||||||
|
rows={yard.toLoad}
|
||||||
|
side="load"
|
||||||
|
trainHere={trainHere}
|
||||||
|
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||||
|
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||||
|
pendingBookingId={pendingLoadId}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" fw={600} c="dimmed">
|
||||||
|
Alight here
|
||||||
|
</Text>
|
||||||
|
<WorkTable
|
||||||
|
rows={yard.toUnload}
|
||||||
|
side="unload"
|
||||||
|
trainHere={trainHere}
|
||||||
|
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||||
|
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||||
|
pendingBookingId={pendingUnloadId}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -326,6 +326,11 @@ export const URL_CONSTANTS = {
|
|||||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||||
|
YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`,
|
||||||
|
BOOKING_LOAD: (id: string, bookingId: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
|
||||||
|
BOOKING_UNLOAD: (id: string, bookingId: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
|
||||||
INTERCITY_CANDIDATES: (id: string) =>
|
INTERCITY_CANDIDATES: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||||
INTERCITY_ACCEPT: (id: string) =>
|
INTERCITY_ACCEPT: (id: string) =>
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
label: "In Transit",
|
label: "In Transit",
|
||||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||||
},
|
},
|
||||||
|
ARRIVED: {
|
||||||
|
label: "Arrived",
|
||||||
|
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
|
},
|
||||||
COMPLETED: {
|
COMPLETED: {
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||||
@@ -208,6 +212,12 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
color: "text-sky-600",
|
color: "text-sky-600",
|
||||||
stage: 4,
|
stage: 4,
|
||||||
},
|
},
|
||||||
|
ARRIVED: {
|
||||||
|
title: "Arrived",
|
||||||
|
description: "Cargo unloaded at its destination yard.",
|
||||||
|
color: "text-emerald-600",
|
||||||
|
stage: 4,
|
||||||
|
},
|
||||||
COMPLETED: {
|
COMPLETED: {
|
||||||
title: "Completed",
|
title: "Completed",
|
||||||
description: "Booking fulfilled.",
|
description: "Booking fulfilled.",
|
||||||
@@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [
|
|||||||
{
|
{
|
||||||
key: "operations",
|
key: "operations",
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
|
statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
|
||||||
},
|
},
|
||||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||||
@@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
statuses: ["PAID", "IN_TRANSIT"],
|
statuses: ["PAID", "IN_TRANSIT", "ARRIVED"],
|
||||||
},
|
},
|
||||||
{ label: "Done", statuses: ["COMPLETED"] },
|
{ label: "Done", statuses: ["COMPLETED"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
|||||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||||
|
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
@@ -585,12 +586,20 @@ const FleetResourcePage = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<FleetHistoryModal
|
{slug === "wagons" ? (
|
||||||
opened={Boolean(historyTarget)}
|
<WagonMovementHistoryModal
|
||||||
onClose={() => setHistoryTarget(null)}
|
opened={Boolean(historyTarget)}
|
||||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
onClose={() => setHistoryTarget(null)}
|
||||||
record={historyTarget}
|
record={historyTarget}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<FleetHistoryModal
|
||||||
|
opened={Boolean(historyTarget)}
|
||||||
|
onClose={() => setHistoryTarget(null)}
|
||||||
|
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||||
|
record={historyTarget}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [
|
|||||||
{ label: "Both", value: "BOTH" },
|
{ label: "Both", value: "BOTH" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
|
||||||
|
const YARD_COUNTRIES = [
|
||||||
|
{ label: "Ethiopia", value: "Ethiopia" },
|
||||||
|
{ label: "Djibouti", value: "Djibouti" },
|
||||||
|
];
|
||||||
|
|
||||||
const APPROVAL_ROLES = [
|
const APPROVAL_ROLES = [
|
||||||
{ label: "Line staff", value: "LINE_STAFF" },
|
{ label: "Line staff", value: "LINE_STAFF" },
|
||||||
{ label: "Director", value: "DIRECTOR" },
|
{ label: "Director", value: "DIRECTOR" },
|
||||||
@@ -431,7 +437,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "label", label: "Label", type: "text", required: true },
|
{ name: "label", label: "Label", type: "text", required: true },
|
||||||
{ name: "country", label: "Country", type: "text", required: true },
|
{
|
||||||
|
name: "country",
|
||||||
|
label: "Country",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: YARD_COUNTRIES,
|
||||||
|
},
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||||
|
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
@@ -1131,6 +1132,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
void detailQuery.refetch();
|
void detailQuery.refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
|
||||||
{scheduleId ? (
|
{scheduleId ? (
|
||||||
<IntercityRideAlongPanel
|
<IntercityRideAlongPanel
|
||||||
scheduleId={scheduleId}
|
scheduleId={scheduleId}
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ import {
|
|||||||
wagonService,
|
wagonService,
|
||||||
type Wagon,
|
type Wagon,
|
||||||
type WagonListFilters,
|
type WagonListFilters,
|
||||||
|
type WagonMovementRecord,
|
||||||
} from "./wagon.service";
|
} from "./wagon.service";
|
||||||
import { warehouseService } from "./warehouse.service";
|
import { warehouseService } from "./warehouse.service";
|
||||||
|
|
||||||
@@ -593,6 +594,40 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
yardWork: endpoint<
|
||||||
|
{ scheduleId: string },
|
||||||
|
import("@/types/trainScheduling").YardWorkResult
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"yard-work",
|
||||||
|
({ scheduleId }) => trainSchedulingService.getYardWork(scheduleId),
|
||||||
|
({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId],
|
||||||
|
),
|
||||||
|
|
||||||
|
loadScheduleBooking: endpoint<
|
||||||
|
{ scheduleId: string; bookingId: string },
|
||||||
|
import("@/types/trainScheduling").BookingLoadResult
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"booking-load",
|
||||||
|
({ scheduleId, bookingId }) =>
|
||||||
|
trainSchedulingService.loadScheduleBooking(scheduleId, bookingId),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
|
unloadScheduleBooking: endpoint<
|
||||||
|
{ scheduleId: string; bookingId: string },
|
||||||
|
import("@/types/trainScheduling").BookingUnloadResult
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"booking-unload",
|
||||||
|
({ scheduleId, bookingId }) =>
|
||||||
|
trainSchedulingService.unloadScheduleBooking(scheduleId, bookingId),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
intercityCandidates: endpoint<
|
intercityCandidates: endpoint<
|
||||||
{ scheduleId: string },
|
{ scheduleId: string },
|
||||||
import("@/types/trainScheduling").IntercityCandidatesResult
|
import("@/types/trainScheduling").IntercityCandidatesResult
|
||||||
@@ -1493,6 +1528,13 @@ export const api = {
|
|||||||
wagonService.getById(id).then((r) => r.data),
|
wagonService.getById(id).then((r) => r.data),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
movements: endpoint<{ id: string }, WagonMovementRecord[]>(
|
||||||
|
"wagons",
|
||||||
|
"movements",
|
||||||
|
({ id }) => wagonService.getMovements(id).then((r) => r.data),
|
||||||
|
({ id }) => ["wagons", "movements", id],
|
||||||
|
),
|
||||||
|
|
||||||
assignToTrain: endpoint<
|
assignToTrain: endpoint<
|
||||||
{ wagonId: string; trainId: string; sequenceNumber?: number },
|
{ wagonId: string; trainId: string; sequenceNumber?: number },
|
||||||
Wagon
|
Wagon
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
BookableSchedule,
|
BookableSchedule,
|
||||||
BookingWindow,
|
BookingWindow,
|
||||||
AssignBookingsPayload,
|
AssignBookingsPayload,
|
||||||
|
BookingLoadResult,
|
||||||
|
BookingUnloadResult,
|
||||||
CompositionRemovalEntry,
|
CompositionRemovalEntry,
|
||||||
UnassignedBookingsResponse,
|
UnassignedBookingsResponse,
|
||||||
CreateTrainSchedulePayload,
|
CreateTrainSchedulePayload,
|
||||||
@@ -35,6 +37,7 @@ import type {
|
|||||||
UploadImportDjiboutiDocumentPayload,
|
UploadImportDjiboutiDocumentPayload,
|
||||||
WagonAllocationAttemptResult,
|
WagonAllocationAttemptResult,
|
||||||
YardOption,
|
YardOption,
|
||||||
|
YardWorkResult,
|
||||||
} from "@/types/trainScheduling";
|
} from "@/types/trainScheduling";
|
||||||
|
|
||||||
interface BookingReferenceDataResponse {
|
interface BookingReferenceDataResponse {
|
||||||
@@ -330,6 +333,35 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getYardWork: async (scheduleId: string): Promise<YardWorkResult> => {
|
||||||
|
const response = await client.get<YardWorkResult>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.YARD_WORK(scheduleId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadScheduleBooking: async (
|
||||||
|
scheduleId: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<BookingLoadResult> => {
|
||||||
|
const response = await client.post<BookingLoadResult>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_LOAD(scheduleId, bookingId),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
unloadScheduleBooking: async (
|
||||||
|
scheduleId: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<BookingUnloadResult> => {
|
||||||
|
const response = await client.post<BookingUnloadResult>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_UNLOAD(scheduleId, bookingId),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
getIntercityCandidates: async (
|
getIntercityCandidates: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
): Promise<IntercityCandidatesResult> => {
|
): Promise<IntercityCandidatesResult> => {
|
||||||
|
|||||||
@@ -37,6 +37,27 @@ export interface WagonListFilters {
|
|||||||
trainId?: string;
|
trainId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the wagon_movements ledger: every physical relocation between
|
||||||
|
* yards — a booking's loaded leg, an empty reposition ride, or a manual staff
|
||||||
|
* correction. Returned newest first by the API.
|
||||||
|
*/
|
||||||
|
export interface WagonMovementRecord {
|
||||||
|
id: string;
|
||||||
|
wagonId: string;
|
||||||
|
fromYardId: string | null;
|
||||||
|
toYardId: string;
|
||||||
|
fromYard?: { id?: string; label?: string; code?: string } | null;
|
||||||
|
toYard?: { id?: string; label?: string; code?: string } | null;
|
||||||
|
trainScheduleId: string | null;
|
||||||
|
bookingId: string | null;
|
||||||
|
kind: Freight.WagonMovementKind;
|
||||||
|
movedByUserId: string | null;
|
||||||
|
occurredAt: string;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const wagonService = {
|
export const wagonService = {
|
||||||
getAll: (filters: WagonListFilters = {}) => {
|
getAll: (filters: WagonListFilters = {}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -49,6 +70,8 @@ export const wagonService = {
|
|||||||
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
|
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
||||||
|
getMovements: (id: string) =>
|
||||||
|
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),
|
||||||
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
||||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||||
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [
|
|||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
"PAID",
|
"PAID",
|
||||||
"IN_TRANSIT",
|
"IN_TRANSIT",
|
||||||
|
"ARRIVED",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"REJECTED",
|
"REJECTED",
|
||||||
"CANCELLED",
|
"CANCELLED",
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export type CustomerBookingStatus =
|
|||||||
| "APPROVED"
|
| "APPROVED"
|
||||||
| "PAID"
|
| "PAID"
|
||||||
| "IN_TRANSIT"
|
| "IN_TRANSIT"
|
||||||
|
| "ARRIVED"
|
||||||
| "COMPLETED"
|
| "COMPLETED"
|
||||||
| "REJECTED"
|
| "REJECTED"
|
||||||
| "CANCELLED";
|
| "CANCELLED";
|
||||||
|
|||||||
@@ -796,3 +796,52 @@ export interface IntercityAcceptResult {
|
|||||||
rejected: Array<{ bookingId: string; reason: string }>;
|
rejected: Array<{ bookingId: string; reason: string }>;
|
||||||
remaining: IntercityCapacity;
|
remaining: IntercityCapacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Yard load / unload worklist ──────────────────────────────────────────────
|
||||||
|
// Per-booking journey along the train's corridor: every booking boards at its
|
||||||
|
// origin yard and alights at its destination yard, confirmed by the yard
|
||||||
|
// operator while the train's latest checkpoint is at that yard.
|
||||||
|
|
||||||
|
export interface YardWorkBookingRow {
|
||||||
|
id: string;
|
||||||
|
reference: string | null;
|
||||||
|
status: string;
|
||||||
|
tradeDirection: string;
|
||||||
|
isGovernment: boolean;
|
||||||
|
customer: string;
|
||||||
|
originYardId: string;
|
||||||
|
destinationYardId: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
loadedAt: string | null;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
canLoad: boolean;
|
||||||
|
canUnload: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YardWorkYard {
|
||||||
|
yardId: string;
|
||||||
|
yard: string;
|
||||||
|
toLoad: YardWorkBookingRow[];
|
||||||
|
toUnload: YardWorkBookingRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YardWorkResult {
|
||||||
|
scheduleId: string;
|
||||||
|
scheduleStatus: string;
|
||||||
|
trainAtYardId: string | null;
|
||||||
|
yards: YardWorkYard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingLoadResult {
|
||||||
|
bookingId: string;
|
||||||
|
status: string;
|
||||||
|
loadedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingUnloadResult {
|
||||||
|
bookingId: string;
|
||||||
|
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
|
||||||
|
status: string;
|
||||||
|
arrivedAt: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ export const ActivityRow = memo(function ActivityRow({
|
|||||||
const verb =
|
const verb =
|
||||||
booking.status === "IN_TRANSIT"
|
booking.status === "IN_TRANSIT"
|
||||||
? "departed"
|
? "departed"
|
||||||
: booking.status === "COMPLETED"
|
: booking.status === "ARRIVED"
|
||||||
? "delivered"
|
? "arrived"
|
||||||
: booking.status === "PENDING_APPROVAL"
|
: booking.status === "COMPLETED"
|
||||||
? "quote ready"
|
? "delivered"
|
||||||
: booking.status === "SUBMITTED"
|
: booking.status === "PENDING_APPROVAL"
|
||||||
? "submitted for review"
|
? "quote ready"
|
||||||
: "created";
|
: booking.status === "SUBMITTED"
|
||||||
|
? "submitted for review"
|
||||||
|
: "created";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const ACTIVE_STATUSES = [
|
|||||||
"SUBMITTED",
|
"SUBMITTED",
|
||||||
"PENDING_APPROVAL",
|
"PENDING_APPROVAL",
|
||||||
"IN_TRANSIT",
|
"IN_TRANSIT",
|
||||||
|
"ARRIVED",
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface StageConfig {
|
export interface StageConfig {
|
||||||
@@ -346,6 +347,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
badgeDot: "edr-green.5",
|
badgeDot: "edr-green.5",
|
||||||
action: { label: "Track", kind: "outline", icon: MapPin },
|
action: { label: "Track", kind: "outline", icon: MapPin },
|
||||||
},
|
},
|
||||||
|
ARRIVED: {
|
||||||
|
stage: 3,
|
||||||
|
icon: MapPin,
|
||||||
|
iconColor: "edr-green.7",
|
||||||
|
tile: "edr-soft",
|
||||||
|
hint: "Arrived at destination yard · awaiting release",
|
||||||
|
step: "edr-green.5",
|
||||||
|
badgeLabel: "Arrived",
|
||||||
|
badgeBg: "edr-soft",
|
||||||
|
badgeText: "edr-green.7",
|
||||||
|
badgeDot: "edr-green.5",
|
||||||
|
action: { label: "Track", kind: "outline", icon: MapPin },
|
||||||
|
},
|
||||||
COMPLETED: {
|
COMPLETED: {
|
||||||
stage: 4,
|
stage: 4,
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ export function ReadonlyBookingView({
|
|||||||
const canAssignCustomerTruck =
|
const canAssignCustomerTruck =
|
||||||
booking.paymentStatus === "PAID" &&
|
booking.paymentStatus === "PAID" &&
|
||||||
usesCustomerTruck &&
|
usesCustomerTruck &&
|
||||||
["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status);
|
["PAID", "IN_TRANSIT", "ARRIVED", "COMPLETED", "TRUCK_ASSIGNED"].includes(
|
||||||
|
status,
|
||||||
|
);
|
||||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||||
const isExpired = status === "EXPIRED";
|
const isExpired = status === "EXPIRED";
|
||||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ export function StatusHero({
|
|||||||
}) {
|
}) {
|
||||||
const status = booking.status;
|
const status = booking.status;
|
||||||
const stage = resolveStage(booking);
|
const stage = resolveStage(booking);
|
||||||
// The Arrival stage has no booking status of its own — it lights up from the
|
// Legacy bookings never reach the ARRIVED status — they light up the Arrival
|
||||||
// train's ARRIVED state, so the headline is overridden here.
|
// stage from the train's ARRIVED state while staying IN_TRANSIT, so the
|
||||||
|
// headline is overridden here. Bookings with a per-booking journey carry the
|
||||||
|
// ARRIVED status themselves and use its own STATUS_MAP copy.
|
||||||
const cfg =
|
const cfg =
|
||||||
stage === ARRIVAL_STAGE
|
stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE
|
||||||
? {
|
? {
|
||||||
title: "Train arrived at destination",
|
title: "Train arrived at destination",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -54,12 +54,13 @@ export const PROGRESS_STAGES = [
|
|||||||
statuses: ["EXPIRED", "IN_TRANSIT"],
|
statuses: ["EXPIRED", "IN_TRANSIT"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// No booking status maps here: the booking stays IN_TRANSIT until
|
// ARRIVED: cargo unloaded at the booking's own destination yard (segment
|
||||||
// delivery, so this stage lights up from the assigned train's own status
|
// corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so
|
||||||
|
// this stage also lights up from the assigned train's own status
|
||||||
// (trainScheduleStatus === "ARRIVED") — see resolveStage.
|
// (trainScheduleStatus === "ARRIVED") — see resolveStage.
|
||||||
label: "Arrival",
|
label: "Arrival",
|
||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
statuses: [],
|
statuses: ["ARRIVED"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Complete",
|
label: "Complete",
|
||||||
@@ -75,8 +76,10 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage for a booking, factoring in the assigned train's operational status:
|
* Stage for a booking, factoring in the assigned train's operational status:
|
||||||
* a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its
|
* a booking with per-booking journey data reaches ARRIVED when it is unloaded
|
||||||
* train has ARRIVED the tracker advances to the Arrival stage.
|
* at its own destination yard; a legacy booking is stuck at IN_TRANSIT between
|
||||||
|
* dispatch and delivery, so once its train has ARRIVED the tracker advances to
|
||||||
|
* the Arrival stage.
|
||||||
*/
|
*/
|
||||||
export function resolveStage(booking: {
|
export function resolveStage(booking: {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -177,6 +180,12 @@ export const STATUS_MAP: Record<
|
|||||||
description: "Your shipment is currently moving through the rail network.",
|
description: "Your shipment is currently moving through the rail network.",
|
||||||
stage: 6,
|
stage: 6,
|
||||||
},
|
},
|
||||||
|
ARRIVED: {
|
||||||
|
title: "Arrived at destination",
|
||||||
|
description:
|
||||||
|
"Your cargo has been unloaded at its destination yard and is being prepared for release.",
|
||||||
|
stage: 7,
|
||||||
|
},
|
||||||
OPERATION_REQUEST_PENDING: {
|
OPERATION_REQUEST_PENDING: {
|
||||||
title: "Operation request under review",
|
title: "Operation request under review",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
const TRACKABLE_STATUSES = new Set([
|
const TRACKABLE_STATUSES = new Set([
|
||||||
"PAID",
|
"PAID",
|
||||||
"IN_TRANSIT",
|
"IN_TRANSIT",
|
||||||
|
"ARRIVED",
|
||||||
"COMPLETED",
|
"COMPLETED",
|
||||||
"DELIVERED",
|
"DELIVERED",
|
||||||
]);
|
]);
|
||||||
@@ -83,7 +84,7 @@ const STATUS_FILTERS = [
|
|||||||
statuses:
|
statuses:
|
||||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
||||||
},
|
},
|
||||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
|
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" },
|
||||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||||
{
|
{
|
||||||
key: "closed",
|
key: "closed",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Flag,
|
Flag,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
PackageCheck,
|
||||||
PackageX,
|
PackageX,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Train,
|
Train,
|
||||||
@@ -15,11 +16,14 @@ import { api } from "@/services/api";
|
|||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
bookingJourneyState,
|
||||||
|
bookingLegRange,
|
||||||
|
bookingShipmentStatusLabel,
|
||||||
checkpointKindLabel,
|
checkpointKindLabel,
|
||||||
corridorProgress,
|
corridorProgress,
|
||||||
isArrived,
|
isArrived,
|
||||||
isDispatched,
|
isDispatched,
|
||||||
shipmentStatusLabel,
|
type BookingJourneyState,
|
||||||
} from "./trackingStages";
|
} from "./trackingStages";
|
||||||
|
|
||||||
const GREEN = "#0EA371";
|
const GREEN = "#0EA371";
|
||||||
@@ -70,6 +74,7 @@ export function ShipmentTrackingModal({
|
|||||||
trainNumber={data?.trainNumber ?? null}
|
trainNumber={data?.trainNumber ?? null}
|
||||||
status={data?.scheduleStatus ?? null}
|
status={data?.scheduleStatus ?? null}
|
||||||
currentSequenceNo={data?.currentSequenceNo ?? -1}
|
currentSequenceNo={data?.currentSequenceNo ?? -1}
|
||||||
|
journey={data ? bookingJourneyState(data) : null}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onRefresh={() => refetch()}
|
onRefresh={() => refetch()}
|
||||||
refreshing={isFetching}
|
refreshing={isFetching}
|
||||||
@@ -95,6 +100,7 @@ export function ShipmentTrackingModal({
|
|||||||
) : data ? (
|
) : data ? (
|
||||||
<Stack gap={26}>
|
<Stack gap={26}>
|
||||||
<SummaryBar data={data} />
|
<SummaryBar data={data} />
|
||||||
|
<BookingJourneyLine data={data} />
|
||||||
<Corridor data={data} />
|
<Corridor data={data} />
|
||||||
<CheckpointFeed data={data} />
|
<CheckpointFeed data={data} />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -111,6 +117,7 @@ function Header({
|
|||||||
trainNumber,
|
trainNumber,
|
||||||
status,
|
status,
|
||||||
currentSequenceNo,
|
currentSequenceNo,
|
||||||
|
journey,
|
||||||
onClose,
|
onClose,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshing,
|
refreshing,
|
||||||
@@ -119,6 +126,7 @@ function Header({
|
|||||||
trainNumber: string | null;
|
trainNumber: string | null;
|
||||||
status: Freight.TrainScheduleStatus | null;
|
status: Freight.TrainScheduleStatus | null;
|
||||||
currentSequenceNo: number;
|
currentSequenceNo: number;
|
||||||
|
journey: BookingJourneyState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
@@ -171,7 +179,11 @@ function Header({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={10} align="center" wrap="nowrap">
|
<Group gap={10} align="center" wrap="nowrap">
|
||||||
<HeaderStatusPill status={status} currentSequenceNo={currentSequenceNo} />
|
<HeaderStatusPill
|
||||||
|
status={status}
|
||||||
|
currentSequenceNo={currentSequenceNo}
|
||||||
|
journey={journey}
|
||||||
|
/>
|
||||||
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
|
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -223,12 +235,17 @@ function IconButton({
|
|||||||
function HeaderStatusPill({
|
function HeaderStatusPill({
|
||||||
status,
|
status,
|
||||||
currentSequenceNo,
|
currentSequenceNo,
|
||||||
|
journey,
|
||||||
}: {
|
}: {
|
||||||
status: Freight.TrainScheduleStatus | null;
|
status: Freight.TrainScheduleStatus | null;
|
||||||
currentSequenceNo: number;
|
currentSequenceNo: number;
|
||||||
|
journey: BookingJourneyState;
|
||||||
}) {
|
}) {
|
||||||
const arrived = isArrived(status);
|
// The booking's own journey wins: a sub-corridor booking can be unloaded
|
||||||
const moving = isDispatched(status);
|
// (arrived) at its own yard while the train is still moving.
|
||||||
|
const arrived = journey === "arrived" || (!journey && isArrived(status));
|
||||||
|
const moving = !arrived && (journey === "in-transit" || isDispatched(status));
|
||||||
|
const label = bookingShipmentStatusLabel(journey, status, currentSequenceNo);
|
||||||
const bg = arrived
|
const bg = arrived
|
||||||
? "rgba(14,163,113,0.22)"
|
? "rgba(14,163,113,0.22)"
|
||||||
: moving
|
: moving
|
||||||
@@ -254,7 +271,7 @@ function HeaderStatusPill({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text fz="12px" fw={700} c="#fff">
|
<Text fz="12px" fw={700} c="#fff">
|
||||||
{shipmentStatusLabel(status, currentSequenceNo)}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -263,7 +280,10 @@ function HeaderStatusPill({
|
|||||||
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
|
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
|
||||||
|
|
||||||
function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
|
function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
|
||||||
const arrived = isArrived(data.scheduleStatus);
|
const journey = bookingJourneyState(data);
|
||||||
|
// Booking-level arrival (unloaded at its own destination yard) counts as
|
||||||
|
// arrived even while the train itself is still moving down the corridor.
|
||||||
|
const arrived = journey === "arrived" || isArrived(data.scheduleStatus);
|
||||||
const items: Array<{ label: string; value: string; accent?: boolean }> = [
|
const items: Array<{ label: string; value: string; accent?: boolean }> = [
|
||||||
{
|
{
|
||||||
label: "Departed",
|
label: "Departed",
|
||||||
@@ -271,7 +291,11 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: arrived ? "Arrived" : "Est. arrival",
|
label: arrived ? "Arrived" : "Est. arrival",
|
||||||
value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
|
value: fmtTime(
|
||||||
|
(journey === "arrived" ? data.arrivedAt : null) ??
|
||||||
|
data.actualArrivalAt ??
|
||||||
|
data.scheduledArrivalAt,
|
||||||
|
),
|
||||||
accent: !arrived,
|
accent: !arrived,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -317,6 +341,66 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-booking journey line (loaded / unloaded at the booking's own yards) ────
|
||||||
|
|
||||||
|
function BookingJourneyLine({ data }: { data: Freight.IBookingTracking }) {
|
||||||
|
if (!data.loadedAt && !data.arrivedAt) return null;
|
||||||
|
|
||||||
|
const stationLabel = (yardId?: string | null) =>
|
||||||
|
data.stations.find((s) => s.yardId === yardId)?.label ?? null;
|
||||||
|
const origin = stationLabel(data.bookingOriginYardId) ?? "origin yard";
|
||||||
|
const destination =
|
||||||
|
stationLabel(data.bookingDestinationYardId) ?? "destination yard";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={10} wrap="wrap">
|
||||||
|
{data.loadedAt && (
|
||||||
|
<JourneyChip
|
||||||
|
icon={<PackageCheck size={14} />}
|
||||||
|
text={`Loaded at ${origin}`}
|
||||||
|
time={fmtTime(data.loadedAt)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data.arrivedAt && (
|
||||||
|
<JourneyChip
|
||||||
|
icon={<CheckCircle2 size={14} />}
|
||||||
|
text={`Arrived at ${destination}`}
|
||||||
|
time={fmtTime(data.arrivedAt)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JourneyChip({
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
text: string;
|
||||||
|
time: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={7}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
px={12}
|
||||||
|
py={7}
|
||||||
|
style={{ borderRadius: 999, background: "#ECF6F1", color: GREEN_DARK }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<Text fz="12px" fw={700} c={GREEN_DARK}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12px" c={MUTED}>
|
||||||
|
· {time}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Corridor: stations + train marker ──────────────────────────────────────────
|
// ── Corridor: stations + train marker ──────────────────────────────────────────
|
||||||
|
|
||||||
function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
||||||
@@ -326,6 +410,14 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
|||||||
const current = data.currentSequenceNo;
|
const current = data.currentSequenceNo;
|
||||||
const progress = corridorProgress(stations.length, current, arrived);
|
const progress = corridorProgress(stations.length, current, arrived);
|
||||||
|
|
||||||
|
// The booking's own leg on the corridor (sub-corridor bookings ride only a
|
||||||
|
// slice of the train's route). Stations outside the leg render dimmed.
|
||||||
|
const leg = bookingLegRange(
|
||||||
|
stations,
|
||||||
|
data.bookingOriginYardId,
|
||||||
|
data.bookingDestinationYardId,
|
||||||
|
);
|
||||||
|
|
||||||
// Map sequenceNo → latest checkpoint at that station for captions.
|
// Map sequenceNo → latest checkpoint at that station for captions.
|
||||||
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
|
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
|
||||||
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
|
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
|
||||||
@@ -415,6 +507,7 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
|||||||
const reached = arrived || (current >= 0 && i <= current);
|
const reached = arrived || (current >= 0 && i <= current);
|
||||||
const isCurrent = !arrived && i === current;
|
const isCurrent = !arrived && i === current;
|
||||||
const isLast = i === stations.length - 1;
|
const isLast = i === stations.length - 1;
|
||||||
|
const onLeg = !leg || (i >= leg.start && i <= leg.end);
|
||||||
const cp = checkpointBySeq.get(s.sequenceNo);
|
const cp = checkpointBySeq.get(s.sequenceNo);
|
||||||
return (
|
return (
|
||||||
<StationNode
|
<StationNode
|
||||||
@@ -424,6 +517,7 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
|||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
isEndpoint={i === 0 || isLast}
|
isEndpoint={i === 0 || isLast}
|
||||||
arrivedHere={isLast && arrived}
|
arrivedHere={isLast && arrived}
|
||||||
|
dimmed={!onLeg}
|
||||||
time={cp ? fmtTime(cp.occurredAt) : null}
|
time={cp ? fmtTime(cp.occurredAt) : null}
|
||||||
align={i === 0 ? "left" : isLast ? "right" : "center"}
|
align={i === 0 ? "left" : isLast ? "right" : "center"}
|
||||||
/>
|
/>
|
||||||
@@ -441,6 +535,7 @@ function StationNode({
|
|||||||
isCurrent,
|
isCurrent,
|
||||||
isEndpoint,
|
isEndpoint,
|
||||||
arrivedHere,
|
arrivedHere,
|
||||||
|
dimmed,
|
||||||
time,
|
time,
|
||||||
align,
|
align,
|
||||||
}: {
|
}: {
|
||||||
@@ -449,6 +544,8 @@ function StationNode({
|
|||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
isEndpoint: boolean;
|
isEndpoint: boolean;
|
||||||
arrivedHere: boolean;
|
arrivedHere: boolean;
|
||||||
|
/** Station lies outside the booking's own leg — render muted. */
|
||||||
|
dimmed: boolean;
|
||||||
time: string | null;
|
time: string | null;
|
||||||
align: "left" | "center" | "right";
|
align: "left" | "center" | "right";
|
||||||
}) {
|
}) {
|
||||||
@@ -462,6 +559,7 @@ function StationNode({
|
|||||||
flex: isEndpoint ? "0 0 auto" : 1,
|
flex: isEndpoint ? "0 0 auto" : 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
maxWidth: 120,
|
maxWidth: 120,
|
||||||
|
opacity: dimmed ? 0.4 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
@@ -488,8 +586,8 @@ function StationNode({
|
|||||||
</Box>
|
</Box>
|
||||||
<Text
|
<Text
|
||||||
fz="11.5px"
|
fz="11.5px"
|
||||||
fw={reached ? 700 : 600}
|
fw={!dimmed && reached ? 700 : 600}
|
||||||
c={reached ? INK : "#9AA8B5"}
|
c={dimmed ? "#9AA8B5" : reached ? INK : "#9AA8B5"}
|
||||||
mt={8}
|
mt={8}
|
||||||
ta={align}
|
ta={align}
|
||||||
truncate
|
truncate
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
const { TrainScheduleStatus } = Freight;
|
const { BookingStatus, TrainScheduleStatus } = Freight;
|
||||||
|
|
||||||
export function isArrived(
|
export function isArrived(
|
||||||
status?: Freight.TrainScheduleStatus | null,
|
status?: Freight.TrainScheduleStatus | null,
|
||||||
@@ -50,6 +50,65 @@ export function corridorProgress(
|
|||||||
return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
|
return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-booking journey (segment corridor bookings) ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The booking's own journey state, independent of the train: a sub-corridor
|
||||||
|
* booking is loaded at its own origin yard and unloaded (ARRIVED) at its own
|
||||||
|
* destination yard while the train may keep going. `null` means the booking
|
||||||
|
* has no per-booking journey data yet (legacy bookings) — callers fall back
|
||||||
|
* to the train-schedule status.
|
||||||
|
*/
|
||||||
|
export type BookingJourneyState = "arrived" | "in-transit" | null;
|
||||||
|
|
||||||
|
export function bookingJourneyState(
|
||||||
|
t: Freight.IBookingTracking,
|
||||||
|
): BookingJourneyState {
|
||||||
|
const status = t.bookingStatus ?? null;
|
||||||
|
if (
|
||||||
|
t.arrivedAt ||
|
||||||
|
status === BookingStatus.Arrived ||
|
||||||
|
status === BookingStatus.Completed ||
|
||||||
|
status === BookingStatus.Delivered
|
||||||
|
) {
|
||||||
|
return "arrived";
|
||||||
|
}
|
||||||
|
if (t.loadedAt) return "in-transit";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header pill label. Prefers the booking's own journey (loaded/unloaded at its
|
||||||
|
* own yards) and falls back to the train-schedule wording for legacy bookings
|
||||||
|
* without per-booking journey data.
|
||||||
|
*/
|
||||||
|
export function bookingShipmentStatusLabel(
|
||||||
|
journey: BookingJourneyState,
|
||||||
|
scheduleStatus: Freight.TrainScheduleStatus | null,
|
||||||
|
currentSequenceNo: number,
|
||||||
|
): string {
|
||||||
|
if (journey === "arrived") return "Arrived";
|
||||||
|
if (journey === "in-transit") return "In transit";
|
||||||
|
return shipmentStatusLabel(scheduleStatus, currentSequenceNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index range [start..end] of the booking's own leg on the corridor, matched
|
||||||
|
* by yardId. Null when the booking rides the full corridor (no leg data) or
|
||||||
|
* either endpoint isn't a station on this train's route.
|
||||||
|
*/
|
||||||
|
export function bookingLegRange(
|
||||||
|
stations: Freight.ITrackingStation[],
|
||||||
|
originYardId?: string | null,
|
||||||
|
destinationYardId?: string | null,
|
||||||
|
): { start: number; end: number } | null {
|
||||||
|
if (!originYardId || !destinationYardId) return null;
|
||||||
|
const start = stations.findIndex((s) => s.yardId === originYardId);
|
||||||
|
const end = stations.findIndex((s) => s.yardId === destinationYardId);
|
||||||
|
if (start < 0 || end < 0) return null;
|
||||||
|
return start <= end ? { start, end } : { start: end, end: start };
|
||||||
|
}
|
||||||
|
|
||||||
/** Caption for a checkpoint kind. */
|
/** Caption for a checkpoint kind. */
|
||||||
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
|
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountdownTimer } from "@edr/ui-common";
|
import { CountdownTimer } from "@edr/ui-common";
|
||||||
|
|
||||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||||
|
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
|
||||||
|
|
||||||
const INK = "#10202F";
|
const INK = "#10202F";
|
||||||
const MUTED = "#6B7C8E";
|
const MUTED = "#6B7C8E";
|
||||||
@@ -209,6 +212,65 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-line status strip above the cards: green when a window is open right now
|
||||||
|
* (the customer can act), neutral with the next opening time otherwise.
|
||||||
|
*/
|
||||||
|
function WindowStatusBanner({ windows }: { windows: MyBookingWindow[] }) {
|
||||||
|
const open = windows.find((w) => w.isOpenNow);
|
||||||
|
if (open) {
|
||||||
|
const lane =
|
||||||
|
open.origin && open.destination
|
||||||
|
? ` on ${open.origin} → ${open.destination}`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={10}
|
||||||
|
wrap="nowrap"
|
||||||
|
px={14}
|
||||||
|
py={10}
|
||||||
|
mb="md"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #CDEBDD",
|
||||||
|
background: "#F4FBF7",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={17} color="#0A6F4D" style={{ flexShrink: 0 }} />
|
||||||
|
<Text fz={13.5} fw={600} c="#0A6F4D">
|
||||||
|
A booking window is open right now{lane} — you can create a shipment
|
||||||
|
booking before it closes.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = soonestUpcomingWindow(windows);
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
gap={10}
|
||||||
|
wrap="nowrap"
|
||||||
|
px={14}
|
||||||
|
py={10}
|
||||||
|
mb="md"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
background: "#F8FAFC",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock size={17} color={MUTED} style={{ flexShrink: 0 }} />
|
||||||
|
<Text fz={13.5} fw={600} style={{ color: MUTED }}>
|
||||||
|
{next?.windowOpensAt
|
||||||
|
? `Booking is not open yet — the next window opens ${formatWindowOpensAt(
|
||||||
|
next.windowOpensAt,
|
||||||
|
)} EAT.`
|
||||||
|
: "Booking is not open right now. You'll see the opening time here once a window is announced."}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ContractBookingWindowsSectionProps {
|
interface ContractBookingWindowsSectionProps {
|
||||||
/** Windows already scoped to this contract's routes/direction by the API. */
|
/** Windows already scoped to this contract's routes/direction by the API. */
|
||||||
windows: MyBookingWindow[];
|
windows: MyBookingWindow[];
|
||||||
@@ -248,8 +310,6 @@ export function ContractBookingWindowsSection({
|
|||||||
safePage * PER_PAGE + PER_PAGE,
|
safePage * PER_PAGE + PER_PAGE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isLoading && sorted.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||||
@@ -313,12 +373,39 @@ export function ContractBookingWindowsSection({
|
|||||||
<Skeleton key={i} height={150} radius="md" />
|
<Skeleton key={i} height={150} radius="md" />
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<Stack
|
||||||
|
align="center"
|
||||||
|
gap={6}
|
||||||
|
py={28}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px dashed ${BORDER}`,
|
||||||
|
background: "#FBFCFE",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarClock size={22} color={MUTED} />
|
||||||
|
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||||
|
No booking windows announced yet
|
||||||
|
</Text>
|
||||||
|
<Text fz={12.5} ta="center" maw={420} style={{ color: MUTED }}>
|
||||||
|
When a train is scheduled on this contract's routes, its
|
||||||
|
booking window will appear here with the opening time.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
<>
|
||||||
{visible.map((w) => (
|
<WindowStatusBanner windows={sorted} />
|
||||||
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
|
<SimpleGrid
|
||||||
))}
|
key={safePage}
|
||||||
</SimpleGrid>
|
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||||
|
spacing="md"
|
||||||
|
>
|
||||||
|
{visible.map((w) => (
|
||||||
|
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -507,23 +507,17 @@ export default function NewContractPage({
|
|||||||
const isContainer = data.cargoType === "container";
|
const isContainer = data.cargoType === "container";
|
||||||
|
|
||||||
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
||||||
// size; bulk: a single commodity row.
|
// size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped
|
||||||
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
|
// (quantityCap omitted → NULL): the customer books repeatedly against a
|
||||||
const isGeneral = data.contractKind === "general_contract";
|
// GENERAL contract until its validity expires.
|
||||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||||
? data.enabledContainerSizes.map((size) => ({
|
? data.enabledContainerSizes.map((size) => ({
|
||||||
containerSize: size,
|
containerSize: size,
|
||||||
quantityCap:
|
|
||||||
isGeneral && data.containerSizeCaps[size]
|
|
||||||
? data.containerSizeCaps[size]
|
|
||||||
: undefined,
|
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||||
cargoFreeText: data.cargoFreeText || undefined,
|
cargoFreeText: data.cargoFreeText || undefined,
|
||||||
quantityCap:
|
|
||||||
isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : undefined,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
|||||||
PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning },
|
PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning },
|
||||||
PAID: { label: "Paid", ...TONE.success },
|
PAID: { label: "Paid", ...TONE.success },
|
||||||
IN_TRANSIT: { label: "In Transit", ...TONE.info },
|
IN_TRANSIT: { label: "In Transit", ...TONE.info },
|
||||||
|
ARRIVED: { label: "Arrived", ...TONE.success },
|
||||||
COMPLETED: { label: "Completed", ...TONE.success },
|
COMPLETED: { label: "Completed", ...TONE.success },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,23 +9,27 @@ import { fieldStyles } from "./shared";
|
|||||||
|
|
||||||
export function PaymentCurrencyField({
|
export function PaymentCurrencyField({
|
||||||
control,
|
control,
|
||||||
|
etbOnly = false,
|
||||||
}: {
|
}: {
|
||||||
control: Control<ContractFormInputValues, any, ContractFormValues>;
|
control: Control<ContractFormInputValues, any, ContractFormValues>;
|
||||||
|
/** Intercity (domestic) contracts are priced in ETB only. */
|
||||||
|
etbOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const options = etbOnly
|
||||||
|
? PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB")
|
||||||
|
: PAYMENT_CURRENCY_OPTIONS;
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name="paymentCurrency"
|
name="paymentCurrency"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState }) => {
|
render={({ field, fieldState }) => {
|
||||||
const selected = PAYMENT_CURRENCY_OPTIONS.find(
|
const selected = options.find((o) => o.value === field.value);
|
||||||
(o) => o.value === field.value,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
label="Payment Currency *"
|
label="Payment Currency *"
|
||||||
placeholder="Select currency…"
|
placeholder="Select currency…"
|
||||||
data={PAYMENT_CURRENCY_OPTIONS.map((o) => ({
|
data={options.map((o) => ({
|
||||||
value: o.value,
|
value: o.value,
|
||||||
label: o.label,
|
label: o.label,
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ export const contractFormSchema = z
|
|||||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||||
previousContractRef: z.string().default(""),
|
previousContractRef: z.string().default(""),
|
||||||
|
|
||||||
serviceTypeId: z.string("Select a service type."),
|
serviceTypeId: z
|
||||||
|
.string("Select a service type.")
|
||||||
|
.min(1, "Select a service type."),
|
||||||
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
|
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
|
||||||
|
|
||||||
firstMile: z
|
firstMile: z
|
||||||
@@ -220,6 +222,14 @@ export const contractFormSchema = z
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
|
// Intercity (domestic) contracts are priced and invoiced in ETB only.
|
||||||
|
if (data.operationType === "intercity" && data.paymentCurrency !== "ETB") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["paymentCurrency"],
|
||||||
|
message: "Intercity contracts are priced in ETB.",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (data.cargoType === "container") {
|
if (data.cargoType === "container") {
|
||||||
// Container scope: at least one enabled size.
|
// Container scope: at least one enabled size.
|
||||||
if (data.enabledContainerSizes.length === 0) {
|
if (data.enabledContainerSizes.length === 0) {
|
||||||
@@ -252,29 +262,10 @@ export const contractFormSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// GENERAL contracts must carry a real (> 0) quantity cap — an untouched
|
// GENERAL contracts are uncapped: no quantity cap is collected, so the
|
||||||
// NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the
|
// customer can book repeatedly until the contract's validity expires. The
|
||||||
// Cargo & Route step until the customer enters a quantity.
|
// cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at
|
||||||
if (data.contractKind === "general_contract") {
|
// the API. No cap validation is applied.
|
||||||
if (data.cargoType === "container") {
|
|
||||||
for (const size of data.enabledContainerSizes) {
|
|
||||||
if (!(data.containerSizeCaps[size] > 0)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["containerSizeCaps", size],
|
|
||||||
message: `Enter a ${size} quantity greater than 0.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["bulkQuantityCap"],
|
|
||||||
message: "Enter a total quantity greater than 0.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ContractFormValues = z.infer<typeof contractFormSchema>;
|
export type ContractFormValues = z.infer<typeof contractFormSchema>;
|
||||||
@@ -286,7 +277,8 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
|||||||
previousContractRef: "",
|
previousContractRef: "",
|
||||||
|
|
||||||
serviceTypeId: "",
|
serviceTypeId: "",
|
||||||
paymentCurrency: "USD",
|
// No preselected currency — the customer must choose (intercity forces ETB).
|
||||||
|
paymentCurrency: undefined,
|
||||||
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
||||||
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||||
equipmentReturn: "with_return",
|
equipmentReturn: "with_return",
|
||||||
|
|||||||
@@ -336,6 +336,16 @@ export function Step2ServiceType({
|
|||||||
}
|
}
|
||||||
}, [operationType, standaloneServices, form]);
|
}, [operationType, standaloneServices, form]);
|
||||||
|
|
||||||
|
// Intercity (domestic) contracts are priced in ETB only — force the currency
|
||||||
|
// and let the field render just the ETB option. Also clears a stale USD from
|
||||||
|
// a restored draft or an operation-type switch.
|
||||||
|
const isIntercity = operationType === "intercity";
|
||||||
|
useEffect(() => {
|
||||||
|
if (isIntercity && form.getValues("paymentCurrency") !== "ETB") {
|
||||||
|
form.setValue("paymentCurrency", "ETB", { shouldValidate: true });
|
||||||
|
}
|
||||||
|
}, [isIntercity, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={18}>
|
<Stack gap={18}>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -353,7 +363,7 @@ export function Step2ServiceType({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Box maw={420}>
|
<Box maw={420}>
|
||||||
<PaymentCurrencyField control={form.control} />
|
<PaymentCurrencyField control={form.control} etbOnly={isIntercity} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showServiceSections && (
|
{showServiceSections && (
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Group,
|
Group,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
NumberInput,
|
|
||||||
Select,
|
Select,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -57,8 +56,6 @@ export function Step3CargoScope({
|
|||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
||||||
const parentId = cargoTypePath[0];
|
const parentId = cargoTypePath[0];
|
||||||
const isGeneral = form.watch("contractKind") === "general_contract";
|
|
||||||
const enabledSizes = form.watch("enabledContainerSizes") ?? [];
|
|
||||||
|
|
||||||
// Reset the commodity child only when the parent group really changes.
|
// Reset the commodity child only when the parent group really changes.
|
||||||
const prevParentIdRef = useRef<string | undefined>(parentId);
|
const prevParentIdRef = useRef<string | undefined>(parentId);
|
||||||
@@ -224,63 +221,9 @@ export function Step3CargoScope({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GENERAL contract quantity cap (draw-down ceiling). */}
|
{/* GENERAL contracts are uncapped — no quantity cap is collected. The
|
||||||
{isGeneral && (
|
customer / GL can book repeatedly against the contract until its
|
||||||
<Box>
|
validity expires (backend stores quantityCap = NULL = uncapped). */}
|
||||||
<StepLabel>Booking quantity cap *</StepLabel>
|
|
||||||
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
|
||||||
Total quantity bookable across all shipments under this contract.
|
|
||||||
Customers / GL can book repeatedly until it is reached. Must be
|
|
||||||
greater than 0.
|
|
||||||
</Text>
|
|
||||||
{cargoType === "container" ? (
|
|
||||||
<Group gap={12} grow align="flex-start">
|
|
||||||
{enabledSizes.length === 0 ? (
|
|
||||||
<Text fz={13} c="dimmed">
|
|
||||||
Select container sizes above to set their caps.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
enabledSizes.map((size) => (
|
|
||||||
<Controller
|
|
||||||
key={size}
|
|
||||||
name={`containerSizeCaps.${size}`}
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<NumberInput
|
|
||||||
label={`${size} cap (containers) *`}
|
|
||||||
placeholder="e.g. 100"
|
|
||||||
min={0}
|
|
||||||
value={Number(field.value ?? 0)}
|
|
||||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
|
||||||
error={fieldState.error?.message}
|
|
||||||
radius={10}
|
|
||||||
styles={fieldStyles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Controller
|
|
||||||
name="bulkQuantityCap"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<NumberInput
|
|
||||||
label="Total cap (tons / items) *"
|
|
||||||
placeholder="e.g. 500"
|
|
||||||
min={0}
|
|
||||||
value={Number(field.value ?? 0)}
|
|
||||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
|
||||||
error={fieldState.error?.message}
|
|
||||||
radius={10}
|
|
||||||
styles={fieldStyles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Shared billing flags. */}
|
{/* Shared billing flags. */}
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export enum BookingStatus {
|
|||||||
Expired = "EXPIRED",
|
Expired = "EXPIRED",
|
||||||
Paid = "PAID",
|
Paid = "PAID",
|
||||||
InTransit = "IN_TRANSIT",
|
InTransit = "IN_TRANSIT",
|
||||||
|
/** Unloaded at the booking's own destination yard (may precede the train's final arrival). */
|
||||||
|
Arrived = "ARRIVED",
|
||||||
Completed = "COMPLETED",
|
Completed = "COMPLETED",
|
||||||
Delivered = "DELIVERED",
|
Delivered = "DELIVERED",
|
||||||
Rejected = "REJECTED",
|
Rejected = "REJECTED",
|
||||||
@@ -255,6 +257,28 @@ export interface ITrainCheckpointEvent extends BaseEntity {
|
|||||||
recordedByUserId?: string | null;
|
recordedByUserId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Why a physical wagon moved between yards — the wagon_movements ledger kind. */
|
||||||
|
export enum WagonMovementKind {
|
||||||
|
/** Carried a booking's cargo for its leg (board yard → alight yard). */
|
||||||
|
Loaded = "LOADED",
|
||||||
|
/** Rode a train empty to reposition for later use. */
|
||||||
|
EmptyReposition = "EMPTY_REPOSITION",
|
||||||
|
/** Staff manually corrected/updated the wagon's yard. */
|
||||||
|
Manual = "MANUAL",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWagonMovement extends BaseEntity {
|
||||||
|
wagonId: string;
|
||||||
|
fromYardId?: string | null;
|
||||||
|
toYardId: string;
|
||||||
|
trainScheduleId?: string | null;
|
||||||
|
bookingId?: string | null;
|
||||||
|
kind: WagonMovementKind;
|
||||||
|
movedByUserId?: string | null;
|
||||||
|
occurredAt: string;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export enum BulkPricingUnit {
|
export enum BulkPricingUnit {
|
||||||
PerWagon = "PER_WAGON",
|
PerWagon = "PER_WAGON",
|
||||||
PerTon = "PER_TON",
|
PerTon = "PER_TON",
|
||||||
@@ -393,12 +417,22 @@ export interface IBookingTracking {
|
|||||||
/** Planned departure/arrival from the schedule, used as ETA hints. */
|
/** Planned departure/arrival from the schedule, used as ETA hints. */
|
||||||
scheduledDepartureAt: string | null;
|
scheduledDepartureAt: string | null;
|
||||||
scheduledArrivalAt: string | null;
|
scheduledArrivalAt: string | null;
|
||||||
|
// ── Per-booking journey (segment corridor bookings) ─────────────────────
|
||||||
|
/** The booking's own status — the journey is per booking, not per train. */
|
||||||
|
bookingStatus?: string | null;
|
||||||
|
/** The leg this booking rides: its own origin/destination yards. */
|
||||||
|
bookingOriginYardId?: string | null;
|
||||||
|
bookingDestinationYardId?: string | null;
|
||||||
|
/** Operator-confirmed load at the booking's origin yard (per-booking dispatch). */
|
||||||
|
loadedAt?: string | null;
|
||||||
|
/** Operator-confirmed unload at the booking's destination yard (per-booking arrival). */
|
||||||
|
arrivedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IYard extends BaseEntity {
|
export interface IYard extends BaseEntity {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
country: string;
|
country: `${YardCountry}`;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
displayOrder: number;
|
displayOrder: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user