mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
Merge pull request #521 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -8,7 +8,10 @@ import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||
import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
EDR_FREIGHT_PERMISSIONS,
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
@@ -108,7 +112,27 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
}
|
||||
await ensurePostgresSchemas(options as DataSourceOptions);
|
||||
const dataSource = new DataSource(options as DataSourceOptions);
|
||||
return dataSource.initialize();
|
||||
await dataSource.initialize();
|
||||
|
||||
// The remote edr_dev DB sits behind a connection pooler/proxy that rejects
|
||||
// the Postgres `options` startup parameter (08P01). Instead of setting
|
||||
// search_path at connect time, apply it per physical connection: the pg
|
||||
// Pool emits `connect` for every new client (initial fill, pool growth,
|
||||
// reconnect), so every backend session gets the schema search order.
|
||||
const pool = (dataSource.driver as { master?: unknown }).master as
|
||||
| { on?: (event: string, cb: (client: unknown) => void) => void }
|
||||
| undefined;
|
||||
if (pool?.on) {
|
||||
pool.on("connect", (client) => {
|
||||
(client as { query: (sql: string) => Promise<unknown> })
|
||||
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
|
||||
.catch(() => {
|
||||
/* connection will be validated on first real query */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return dataSource;
|
||||
},
|
||||
}),
|
||||
SharedAuthModule,
|
||||
@@ -165,6 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
PricingDataSeeder,
|
||||
@@ -188,6 +213,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
@@ -209,6 +235,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
NotificationTemplate,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
|
||||
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
|
||||
|
||||
const iamEntities = [
|
||||
DefaultPosition,
|
||||
@@ -105,14 +104,12 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_freight",
|
||||
schema: "public",
|
||||
// The `-c search_path=...` startup option is rejected by transaction-pooling
|
||||
// poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When
|
||||
// behind such a pooler set DB_PGBOUNCER=true and instead make the search_path
|
||||
// a role default: ALTER ROLE <user> IN DATABASE <db> SET search_path TO
|
||||
// public,iam,freight,audit;
|
||||
...(process.env.DB_PGBOUNCER === "true"
|
||||
? {}
|
||||
: { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }),
|
||||
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
|
||||
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
|
||||
// proxies fronting the remote edr_dev DB) reject with
|
||||
// `08P01 unsupported startup parameter in options: search_path`.
|
||||
// The search_path is instead applied per-connection via a pool `connect`
|
||||
// handler in app.module.ts (see setPoolSearchPath).
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
autoLoadEntities: true,
|
||||
migrations: [
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Moves service-level priority off the service_types table and onto the
|
||||
* admin-managed priority_configs table as a new CUSTOMS rule type.
|
||||
*
|
||||
* - Drops service_types.priority_bonus_points (replaced by CUSTOMS configs).
|
||||
* - Widens priority_configs.type CHECK to allow 'CUSTOMS' (currency must be
|
||||
* null, same as WAGON).
|
||||
* - Seeds the two customs wagon-count tiers: 1–10 → 7 pts, 11–53 → 15 pts.
|
||||
* CUSTOMS rules apply only when the booking's service type includesCustoms.
|
||||
*/
|
||||
export class AddCustomsPriorityConfig2000000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.service_types DROP COLUMN IF EXISTS priority_bonus_points;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT priority_configs_type_check
|
||||
CHECK (type IN ('WAGON', 'CURRENCY', 'CUSTOMS'));
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL) OR
|
||||
(type = 'CUSTOMS' AND currency IS NULL)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.priority_configs
|
||||
(type, label, currency, min_wagon_count, max_wagon_count, score_points, is_active, display_order)
|
||||
VALUES
|
||||
('CUSTOMS', 'With customs 1–10 wagons', NULL, 1, 10, 7, true, 1),
|
||||
('CUSTOMS', 'With customs 11–53 wagons', NULL, 11, 53, 15, true, 2);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.priority_configs WHERE type = 'CUSTOMS';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT priority_configs_type_check
|
||||
CHECK (type IN ('WAGON', 'CURRENCY'));
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.service_types
|
||||
ADD COLUMN IF NOT EXISTS priority_bonus_points INT NOT NULL DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds bookings.consolidation_resume_status: the status a booking parked in
|
||||
* PENDING_CONSOLIDATION returns to once it pairs with a wagon partner.
|
||||
*
|
||||
* Direct customer bookings leave it NULL (they resume to SUBMITTED, unchanged).
|
||||
* Contract-drawdown bookings (GL shipments) set it to the status
|
||||
* createUnderContract would otherwise have used (OPERATION_REQUEST_PENDING or
|
||||
* AWAITING_DOCUMENTS), so pairing resumes them into the contract-booking flow
|
||||
* instead of wrongly moving them to SUBMITTED.
|
||||
*/
|
||||
export class AddConsolidationResumeStatus2010000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS consolidation_resume_status VARCHAR(40);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS consolidation_resume_status;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PAID'],
|
||||
statuses: ['IN_TRANSIT', 'ARRIVED', 'PAID'],
|
||||
},
|
||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||
|
||||
@@ -102,6 +102,7 @@ export function computeNextStep(
|
||||
description: 'Mark shipment as in transit',
|
||||
};
|
||||
case 'IN_TRANSIT':
|
||||
case 'ARRIVED':
|
||||
return {
|
||||
action: 'COMPLETE',
|
||||
description: 'Mark shipment complete',
|
||||
|
||||
@@ -485,7 +485,7 @@ export class BookingTransitionService {
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT"]);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "COMPLETED",
|
||||
|
||||
@@ -114,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
],
|
||||
|
||||
@@ -272,26 +272,51 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
|
||||
* accept them into the approval chain; the link itself (consolidationPartnerId)
|
||||
* marks them as consolidated in the UI.
|
||||
* Pair two bookings for consolidation. Each returns to its own resume status —
|
||||
* SUBMITTED for a direct customer booking (so staff can accept it into the
|
||||
* approval chain) or the stored consolidationResumeStatus for a contract
|
||||
* drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself
|
||||
* (consolidationPartnerId) marks them as consolidated in the UI. The resume
|
||||
* status is cleared once used, so a later un-pair re-parks cleanly.
|
||||
*/
|
||||
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
const [booking, partner] = await Promise.all([
|
||||
this.repository.findOne({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, consolidationResumeStatus: true },
|
||||
}),
|
||||
this.repository.findOne({
|
||||
where: { id: partnerId },
|
||||
select: { id: true, consolidationResumeStatus: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: 'SUBMITTED',
|
||||
status: booking?.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: 'SUBMITTED',
|
||||
status: partner?.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Park a booking that needs consolidation but has no partner yet. */
|
||||
async parkForConsolidation(bookingId: string): Promise<void> {
|
||||
/**
|
||||
* Park a booking that needs consolidation but has no partner yet. The optional
|
||||
* resumeStatus is where the booking returns once it pairs — pass it for a
|
||||
* contract drawdown so pairing resumes the contract-booking flow rather than
|
||||
* the direct-booking SUBMITTED default.
|
||||
*/
|
||||
async parkForConsolidation(
|
||||
bookingId: string,
|
||||
resumeStatus?: string | null,
|
||||
): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
consolidationResumeStatus: resumeStatus ?? null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
@@ -1019,6 +1044,81 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commercial bookings on the day's corridor whose operation request was NOT
|
||||
* accepted by staff (still pending / changes / price-confirm) and are not yet
|
||||
* linked to a train. These never reached FULLY_EXECUTED, so they never enter the
|
||||
* batch pool; the window's doc-review end sweeps them to EXPIRED. Government
|
||||
* bookings are excluded (they don't go through the customer window).
|
||||
*/
|
||||
findUnacceptedForRouteDay(
|
||||
corridorYardIds: string[],
|
||||
day: string,
|
||||
): Promise<Booking[]> {
|
||||
if (corridorYardIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.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')
|
||||
.andWhere(
|
||||
`booking.status IN (
|
||||
'OPERATION_REQUESTED',
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'OPERATION_PRICE_PENDING_CONFIRM'
|
||||
)`,
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
@@ -99,6 +100,7 @@ export class BookingsService {
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
@@ -493,6 +495,14 @@ export class BookingsService {
|
||||
messages.push(
|
||||
this.consolidationService.describePaired(partner.reference, slots),
|
||||
);
|
||||
// Let deferred owners (e.g. contract drawdowns whose invoice/milestones
|
||||
// were held while the booking waited) finalize now that a whole wagon
|
||||
// exists. Fire-and-forget: a listener failure must not undo the pairing.
|
||||
this.events
|
||||
.emitAsync('booking.consolidation.paired', {
|
||||
bookingIds: [booking.id, partner.id],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return { booking: paired, messages };
|
||||
}
|
||||
|
||||
@@ -605,10 +615,15 @@ export class BookingsService {
|
||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||
throw new BadRequestException('Selected schedule is no longer accepting bookings');
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== dto.originYardId ||
|
||||
schedule.destinationStationId !== dto.destinationYardId
|
||||
) {
|
||||
// Corridor-aware: the booking's leg must lie on the schedule's route in
|
||||
// stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti
|
||||
// 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');
|
||||
}
|
||||
} else if (dto.scheduledDate) {
|
||||
@@ -1278,6 +1293,14 @@ export class BookingsService {
|
||||
): Promise<Freight.IBookingTracking> {
|
||||
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 = {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
@@ -1295,6 +1318,7 @@ export class BookingsService {
|
||||
actualArrivalAt: null,
|
||||
scheduledDepartureAt: null,
|
||||
scheduledArrivalAt: null,
|
||||
...journey,
|
||||
};
|
||||
|
||||
if (!booking.trainScheduleId) {
|
||||
@@ -1331,6 +1355,7 @@ export class BookingsService {
|
||||
actualArrivalAt: track.actualArrivalAt,
|
||||
scheduledDepartureAt: track.scheduledDepartureAt,
|
||||
scheduledArrivalAt: track.scheduledArrivalAt,
|
||||
...journey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1607,7 +1632,7 @@ export class BookingsService {
|
||||
if (!booking.isGovernment) {
|
||||
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)) {
|
||||
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const BOOKING_STATUSES = [
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
@@ -430,6 +431,14 @@ export class Booking extends BaseEntity {
|
||||
@JoinColumn({ name: 'consolidation_partner_id' })
|
||||
consolidationPartner?: Booking | null;
|
||||
|
||||
// Status a booking parked in PENDING_CONSOLIDATION returns to once it pairs.
|
||||
// Null for direct customer bookings (they resume to SUBMITTED, the historical
|
||||
// default); contract-drawdown bookings set it to the status createUnderContract
|
||||
// would otherwise have used (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS), so
|
||||
// pairing resumes them into the right flow instead of the direct-booking one.
|
||||
@Column({ name: 'consolidation_resume_status', type: 'varchar', length: 40, nullable: true })
|
||||
consolidationResumeStatus?: string | null;
|
||||
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
wagonsRequired?: number | null;
|
||||
|
||||
@@ -458,6 +467,24 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
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. */
|
||||
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
||||
paymentDeadline?: Date | null;
|
||||
|
||||
@@ -21,6 +21,7 @@ const COMMITTED_STATUSES = [
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'COMPLETED',
|
||||
'DELIVERED',
|
||||
'CONSOLIDATED',
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The GL contract-drawdown path must run wagon consolidation before invoicing.
|
||||
* A partial-wagon drawdown (e.g. 21× 20FT → one leftover container) parks in
|
||||
* PENDING_CONSOLIDATION and is NOT finalized (no invoice / milestones) until it
|
||||
* pairs with a wagon partner. These tests exercise the two new hooks directly.
|
||||
*/
|
||||
describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
function makeService(overrides: {
|
||||
consolidationService?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
invoiceService?: Partial<Record<string, jest.Mock>>;
|
||||
milestoneService?: Partial<Record<string, jest.Mock>>;
|
||||
contractsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
}) {
|
||||
const consolidationService = {
|
||||
slotsFromBooking: jest.fn().mockResolvedValue([]),
|
||||
describePaired: jest.fn().mockReturnValue('paired'),
|
||||
describePending: jest.fn().mockReturnValue('pending'),
|
||||
needsConsolidationFromBooking: jest.fn().mockResolvedValue(false),
|
||||
...overrides.consolidationService,
|
||||
};
|
||||
const bookingsRepository = {
|
||||
findConsolidationPartner: jest.fn().mockResolvedValue(null),
|
||||
pairConsolidation: jest.fn().mockResolvedValue(undefined),
|
||||
parkForConsolidation: jest.fn().mockResolvedValue(undefined),
|
||||
findByIdWithFiles: jest.fn(),
|
||||
...overrides.bookingsRepository,
|
||||
};
|
||||
const invoiceService = {
|
||||
ensureInvoiceForBooking: jest.fn().mockResolvedValue({ id: 'inv-1' }),
|
||||
...overrides.invoiceService,
|
||||
};
|
||||
const milestoneService = {
|
||||
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
||||
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.milestoneService,
|
||||
};
|
||||
const contractsRepository = {
|
||||
findByIdWithRelations: jest.fn(),
|
||||
currentCycle: jest.fn().mockResolvedValue(null),
|
||||
linkBooking: jest.fn().mockResolvedValue(undefined),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.contractsRepository,
|
||||
};
|
||||
|
||||
const service = new ContractBookingService(
|
||||
contractsRepository as never,
|
||||
bookingsRepository as never,
|
||||
{} as never, // bookingPricingService
|
||||
consolidationService as never,
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
milestoneService as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
consolidationService,
|
||||
bookingsRepository,
|
||||
invoiceService,
|
||||
milestoneService,
|
||||
contractsRepository,
|
||||
};
|
||||
}
|
||||
|
||||
const booking = { id: 'b-1', reference: 'BK-1' } as Booking;
|
||||
|
||||
it('parks (not pairs) when no complementary partner exists', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
consolidationService: {
|
||||
slotsFromBooking: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
|
||||
},
|
||||
bookingsRepository: {
|
||||
findConsolidationPartner: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (service as never as {
|
||||
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
|
||||
}).consolidateDrawdown(booking, 'OPERATION_REQUEST_PENDING');
|
||||
|
||||
expect(result.paired).toBe(false);
|
||||
expect(bookingsRepository.parkForConsolidation).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
);
|
||||
expect(bookingsRepository.pairConsolidation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pairs when a complementary partner exists', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
consolidationService: {
|
||||
slotsFromBooking: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
|
||||
},
|
||||
bookingsRepository: {
|
||||
findConsolidationPartner: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'p-1', reference: 'BK-2' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (service as never as {
|
||||
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
|
||||
}).consolidateDrawdown(booking, 'AWAITING_DOCUMENTS');
|
||||
|
||||
expect(result.paired).toBe(true);
|
||||
expect(bookingsRepository.pairConsolidation).toHaveBeenCalledWith('b-1', 'p-1');
|
||||
expect(bookingsRepository.parkForConsolidation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onConsolidationPaired finalizes a resumed contract booking (invoice + milestones)', async () => {
|
||||
const paired = {
|
||||
id: 'b-1',
|
||||
reference: 'BK-1',
|
||||
contractId: 'c-1',
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
} as Booking;
|
||||
const contract = {
|
||||
id: 'c-1',
|
||||
contractKind: 'GENERAL',
|
||||
customsClearingEnabled: true,
|
||||
tradeDirection: 'EXPORT',
|
||||
};
|
||||
const { service, invoiceService, milestoneService } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(paired),
|
||||
},
|
||||
contractsRepository: {
|
||||
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
|
||||
},
|
||||
});
|
||||
|
||||
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
|
||||
|
||||
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||
// GENERAL customs → per-booking pre + post milestones.
|
||||
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
|
||||
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
|
||||
const stillPending = {
|
||||
id: 'b-1',
|
||||
contractId: 'c-1',
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as Booking;
|
||||
const { service, invoiceService } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(stillPending),
|
||||
},
|
||||
});
|
||||
|
||||
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
|
||||
|
||||
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onConsolidationPaired ignores a non-contract (direct) booking', async () => {
|
||||
const direct = { id: 'd-1', status: 'SUBMITTED', contractId: null } as Booking;
|
||||
const { service, invoiceService, contractsRepository } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(direct),
|
||||
},
|
||||
});
|
||||
|
||||
await service.onConsolidationPaired({ bookingIds: ['d-1'] });
|
||||
|
||||
expect(contractsRepository.findByIdWithRelations).not.toHaveBeenCalled();
|
||||
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -15,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
@@ -61,6 +63,7 @@ export class ContractBookingService {
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
@@ -232,6 +235,105 @@ export class ContractBookingService {
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
||||
// wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
|
||||
// before it can ship. Direct bookings do this at submit; drawdowns have no
|
||||
// submit step, so we run it here — BEFORE invoicing/milestones. When it parks
|
||||
// for a partner the booking is NOT invoiced or scheduled: those steps run
|
||||
// later in finalizeContractBooking, triggered by the pairing event. When it
|
||||
// pairs (or needs no consolidation) we finalize inline.
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
const intendedStatus = generalCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(
|
||||
withContainers,
|
||||
))
|
||||
) {
|
||||
const parked = await this.consolidateDrawdown(
|
||||
withContainers,
|
||||
intendedStatus,
|
||||
);
|
||||
warnings.push(parked.message);
|
||||
if (!parked.paired) {
|
||||
// Waiting for a partner — stop here. The booking sits in
|
||||
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
|
||||
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
return { booking: pendingResult ?? booking, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
await this.finalizeContractBooking(
|
||||
booking.id,
|
||||
contract,
|
||||
generalCustoms,
|
||||
);
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
|
||||
* emits booking.consolidation.paired, which finalizes any deferred contract
|
||||
* booking. Returns whether a partner was found plus a customer-facing message.
|
||||
*/
|
||||
private async consolidateDrawdown(
|
||||
booking: Booking,
|
||||
resumeStatus: string,
|
||||
): Promise<{ paired: boolean; message: string }> {
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
if (!slots.length) {
|
||||
return { paired: false, message: '' };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
return {
|
||||
paired: true,
|
||||
message: this.consolidationService.describePaired(
|
||||
partner.reference,
|
||||
slots,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
|
||||
return {
|
||||
paired: false,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a contract booking once it is cleared to proceed (needed no
|
||||
* consolidation, or has just paired): seed clearance milestones / link the
|
||||
* contract cycle, then generate the invoice. Idempotent — safe to call again
|
||||
* for a booking that pairs after having waited. Skips a booking that is still
|
||||
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
|
||||
*/
|
||||
private async finalizeContractBooking(
|
||||
bookingId: string,
|
||||
contract: Contract,
|
||||
generalCustoms: boolean,
|
||||
): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
|
||||
|
||||
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
||||
@@ -239,10 +341,10 @@ export class ContractBookingService {
|
||||
if (contract.customsClearingEnabled && !generalCustoms) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
await this.contractsRepository.linkBooking(cycle.id, bookingId);
|
||||
}
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
@@ -252,24 +354,22 @@ export class ContractBookingService {
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed full milestone timeline on the booking.
|
||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
|
||||
// Contract bookings are born past the billable gate (the contract is already
|
||||
// executed), so the invoice is generated here — they never pass through the
|
||||
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||
// Skips silently when unbillable (no company / no priced amount).
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(result ?? booking)
|
||||
.ensureInvoiceForBooking(booking)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||
@@ -277,8 +377,40 @@ export class ContractBookingService {
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { booking: result ?? booking, warnings };
|
||||
/**
|
||||
* A parked drawdown just paired — finalize whichever partner is a contract
|
||||
* booking that was waiting (invoice + milestones deferred at creation). The
|
||||
* pairing already resumed the booking's status from consolidationResumeStatus;
|
||||
* this runs the create-time tail that was skipped. Non-contract partners have
|
||||
* their own finalize path (staff accept) and are ignored here.
|
||||
*/
|
||||
@OnEvent('booking.consolidation.paired')
|
||||
async onConsolidationPaired(payload: {
|
||||
bookingIds: string[];
|
||||
}): Promise<void> {
|
||||
for (const id of payload.bookingIds ?? []) {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
|
||||
continue;
|
||||
}
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
booking.contractId,
|
||||
);
|
||||
if (!contract) continue;
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
|
||||
(err) =>
|
||||
this.logger.error(
|
||||
`Failed to finalize paired contract booking ${booking.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -202,15 +202,22 @@ export class GlOperationsService {
|
||||
.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 {
|
||||
scheduleId: schedule?.id ?? null,
|
||||
wagonAllocated,
|
||||
departedAt: schedule?.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
: null,
|
||||
arrivedAt: schedule?.actualArrivalAt
|
||||
? new Date(schedule.actualArrivalAt).toISOString()
|
||||
: null,
|
||||
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
|
||||
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,7 +285,7 @@ export class GlOperationsService {
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||||
* 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(
|
||||
bookingId: string,
|
||||
@@ -304,11 +311,8 @@ export class GlOperationsService {
|
||||
if (state.closed) {
|
||||
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
||||
}
|
||||
if (state.trainDepartedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has departed — T1 transport documents can no longer be changed.',
|
||||
);
|
||||
}
|
||||
// Departure no longer locks T1 docs — GL DJ may replace them any time until
|
||||
// GL Ethiopia closes/accepts the T1.
|
||||
|
||||
await persistT1TransportUploads(this.filesService, bookingId, files);
|
||||
return { uploaded: files.length };
|
||||
@@ -395,14 +399,20 @@ export class GlOperationsService {
|
||||
}
|
||||
if (!file) throw new BadRequestException('Attach the invoice document.');
|
||||
|
||||
// Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a
|
||||
// DJ doc milestone that may never be recorded — once the Djibouti gate pass
|
||||
// is secured. The invoice itself stays optional; nothing forces GL DJ to send one.
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const offloaded = milestones.find(
|
||||
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!offloaded) {
|
||||
throw new BadRequestException(
|
||||
'Cargo must be offloaded before the final invoice can be raised.',
|
||||
);
|
||||
const gatepass = await this.gatepassForBooking(bookingId);
|
||||
if (!gatepass.granted) {
|
||||
throw new BadRequestException(
|
||||
'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.billingService.findInvoice(
|
||||
|
||||
@@ -276,6 +276,7 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'PAID',
|
||||
'COMPLETED',
|
||||
'CONTRACT_ACTIVE',
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
|
||||
@Entity({
|
||||
// Table lives in the freight schema like every other freight entity. Without
|
||||
// this the entity inherits the DataSource default schema (public), so TypeORM
|
||||
// queries public.otp_verifications — which doesn't exist — and OTP verify
|
||||
// (e.g. the contract-signature sudo gate) fails with a 500 QueryFailedError.
|
||||
schema: "freight",
|
||||
name: "otp_verifications",
|
||||
})
|
||||
export class OtpVerification extends BaseEntity{
|
||||
|
||||
@@ -21,7 +21,7 @@ export class PriorityConfigsController {
|
||||
@ApiOperation({ summary: 'List priority configs' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined,
|
||||
type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined,
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
|
||||
@@ -2,9 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreatePriorityConfigDto {
|
||||
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
|
||||
@IsIn(['WAGON', 'CURRENCY'])
|
||||
type!: 'WAGON' | 'CURRENCY';
|
||||
@ApiProperty({
|
||||
description: 'Config type: WAGON, CURRENCY, or CUSTOMS',
|
||||
enum: ['WAGON', 'CURRENCY', 'CUSTOMS'],
|
||||
})
|
||||
@IsIn(['WAGON', 'CURRENCY', 'CUSTOMS'])
|
||||
type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
|
||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||
@IsString()
|
||||
@@ -12,7 +15,8 @@ export class CreatePriorityConfigDto {
|
||||
label!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON',
|
||||
description:
|
||||
'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON and type=CUSTOMS',
|
||||
maxLength: 5,
|
||||
})
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateServiceTypeDto {
|
||||
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
||||
@@ -32,17 +32,6 @@ export class CreateServiceTypeDto {
|
||||
@IsBoolean()
|
||||
includesCustoms?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Priority bonus points awarded when this service is used (0–15)',
|
||||
default: 0,
|
||||
maximum: 15,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(15)
|
||||
priorityBonusPoints?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
@Index(['currency', 'type'])
|
||||
export class PriorityConfig extends BaseEntity {
|
||||
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||
type!: 'WAGON' | 'CURRENCY';
|
||||
type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||
label!: string;
|
||||
|
||||
@@ -27,9 +27,6 @@ export class ServiceType extends BaseEntity {
|
||||
@Column({ name: 'includes_customs', type: 'boolean', default: false })
|
||||
includesCustoms!: boolean;
|
||||
|
||||
@Column({ name: 'priority_bonus_points', type: 'int', default: 0 })
|
||||
priorityBonusPoints!: number;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
|
||||
@@ -176,13 +176,12 @@ export class RuleEngineService {
|
||||
}
|
||||
|
||||
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
|
||||
if (serviceType) {
|
||||
priorityScore += serviceType.priorityBonusPoints;
|
||||
}
|
||||
const includesCustoms = serviceType?.includesCustoms ?? false;
|
||||
|
||||
// Additive priority blocks, each keyed on the booking's total wagon count:
|
||||
// - WAGON rules apply regardless of currency.
|
||||
// - CURRENCY rules apply only when the payment currency matches.
|
||||
// - CUSTOMS rules apply only when the service type includes customs.
|
||||
const priorityConfigs = await this.priorityConfigsRepo.findAllActive();
|
||||
const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) =>
|
||||
input.totalWagons >= cfg.minWagonCount &&
|
||||
@@ -191,7 +190,8 @@ export class RuleEngineService {
|
||||
for (const cfg of priorityConfigs) {
|
||||
const applies =
|
||||
cfg.type === 'WAGON' ||
|
||||
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency);
|
||||
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) ||
|
||||
(cfg.type === 'CUSTOMS' && includesCustoms);
|
||||
if (applies && wagonsInRange(cfg)) {
|
||||
priorityScore += cfg.scorePoints;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export class PriorityConfigsService {
|
||||
) {}
|
||||
|
||||
async findAll(filter: {
|
||||
type?: 'WAGON' | 'CURRENCY';
|
||||
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -87,12 +87,15 @@ export class PriorityConfigsService {
|
||||
await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction);
|
||||
}
|
||||
|
||||
private validateCurrencyField(type: 'WAGON' | 'CURRENCY', currency: string | undefined | null): void {
|
||||
private validateCurrencyField(
|
||||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||
currency: string | undefined | null,
|
||||
): void {
|
||||
if (type === 'CURRENCY' && !currency) {
|
||||
throw new BadRequestException('currency field is required when type is CURRENCY');
|
||||
}
|
||||
if (type === 'WAGON' && currency) {
|
||||
throw new BadRequestException('currency field must be null when type is WAGON');
|
||||
if (type !== 'CURRENCY' && currency) {
|
||||
throw new BadRequestException(`currency field must be null when type is ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ export class ServiceTypesService {
|
||||
includesFirstMile: dto.includesFirstMile ?? false,
|
||||
includesLastMile: dto.includesLastMile ?? false,
|
||||
includesCustoms: dto.includesCustoms ?? false,
|
||||
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
|
||||
isActive: dto.isActive ?? true,
|
||||
displayOrder,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findBatchPoolByRouteDay: jest.Mock;
|
||||
findBatchPoolByCorridorDay: jest.Mock;
|
||||
findUnacceptedForRouteDay: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -53,6 +55,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
|
||||
findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -121,7 +125,10 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{
|
||||
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
|
||||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||||
} as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
@@ -196,6 +203,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainers: [],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -227,13 +236,15 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
trainSetId: `set-${id}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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).
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||
commercial('hi', 30),
|
||||
commercial('mid', 20),
|
||||
commercial('lo', 10),
|
||||
@@ -241,9 +252,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
|
||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith(
|
||||
[originYardId, destinationYardId],
|
||||
day,
|
||||
);
|
||||
// Both trains were processed.
|
||||
@@ -258,7 +268,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -286,9 +296,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
freightType: 'CONTAINER',
|
||||
consolidationPartnerId: partnerId,
|
||||
bookingContainers: [{ quantity: 1 }],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
}) as unknown as Booking;
|
||||
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||
consol('a', 'b', 30),
|
||||
consol('b', 'a', 20),
|
||||
]);
|
||||
@@ -313,9 +325,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
freightType: 'CONTAINER',
|
||||
consolidationPartnerId: 'missing-partner',
|
||||
bookingContainers: [{ quantity: 1 }],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
} as unknown as Booking;
|
||||
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
@@ -323,4 +337,177 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
|
||||
const originYardId = 'yard-origin';
|
||||
const destinationYardId = 'yard-dest';
|
||||
const day = '2026-06-20';
|
||||
|
||||
const pendingBooking = {
|
||||
id: 'pending-1',
|
||||
reference: 'BK-PENDING-1',
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
isGovernment: false,
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
} as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
// One fillable schedule on this corridor/day so corridorYardsForRouteDay
|
||||
// resolves a non-empty yard set (legacy two-stop route → [origin, dest]).
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'sched-1',
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('expires each un-accepted booking and clears its scheduled day', async () => {
|
||||
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]);
|
||||
|
||||
await service.expireUnacceptedForRouteDay({
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
});
|
||||
|
||||
expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([originYardId, destinationYardId]),
|
||||
day,
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'pending-1',
|
||||
expect.objectContaining({
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
scheduledDate: null,
|
||||
}),
|
||||
);
|
||||
expect(notifier.expired).toHaveBeenCalledWith(pendingBooking);
|
||||
});
|
||||
|
||||
it('is a no-op when nothing is un-accepted', async () => {
|
||||
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]);
|
||||
|
||||
await service.expireUnacceptedForRouteDay({
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
});
|
||||
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
expect(notifier.expired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the route-day has no fillable schedule', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([]);
|
||||
|
||||
await service.expireUnacceptedForRouteDay({
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
});
|
||||
|
||||
expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||||
const importGeneral = {
|
||||
id: 'b1',
|
||||
reference: 'b1',
|
||||
isGovernment: false,
|
||||
tradeDirection: 'IMPORT',
|
||||
contractKind: 'GENERAL',
|
||||
consolidationPartnerId: null,
|
||||
} as unknown as Booking;
|
||||
|
||||
const call = (booking: Booking, isPair: boolean): boolean =>
|
||||
(
|
||||
service as unknown as {
|
||||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||
}
|
||||
).isSplitEligible(booking, isPair);
|
||||
|
||||
it('allows IMPORT + GENERAL when splitService is present', () => {
|
||||
const withSplit = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
undefined,
|
||||
{ findOpenOffer: jest.fn() } as never,
|
||||
);
|
||||
const eligible = (
|
||||
withSplit as unknown as {
|
||||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||
}
|
||||
).isSplitEligible(importGeneral, false);
|
||||
expect(eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => {
|
||||
const withSplit = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
undefined,
|
||||
{ findOpenOffer: jest.fn() } as never,
|
||||
);
|
||||
const eligible = (
|
||||
withSplit as unknown as {
|
||||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||
}
|
||||
).isSplitEligible(
|
||||
{ ...importGeneral, contractKind: 'ONE_TIME' } as Booking,
|
||||
false,
|
||||
);
|
||||
expect(eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when splitService is absent (default test service)', () => {
|
||||
// `service` from the outer beforeEach was built without a splitService.
|
||||
expect(call(importGeneral, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects EXPORT, government, consolidated pairs, and other directions', () => {
|
||||
const withSplit = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
undefined,
|
||||
{ findOpenOffer: jest.fn() } as never,
|
||||
);
|
||||
const check = (
|
||||
withSplit as unknown as {
|
||||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||
}
|
||||
).isSplitEligible.bind(withSplit);
|
||||
|
||||
expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false);
|
||||
expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false);
|
||||
expect(check(importGeneral, true)).toBe(false); // consolidated pair
|
||||
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.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 { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
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 { BookingWindowGateway } from './booking-window.gateway';
|
||||
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 interface Capacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
export type { Capacity } from './corridor-capacity.util';
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
@@ -78,6 +80,10 @@ export interface BatchBoardBooking {
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
/** Rule-engine priority score used to rank the batch (higher = boards first). */
|
||||
priorityScore: number;
|
||||
/** CONTAINER | BULK — for the priority-tracking visuals. */
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
@@ -459,18 +465,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
throw new BadRequestException('Booking has no scheduled date');
|
||||
}
|
||||
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({
|
||||
where: [
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
@@ -493,6 +495,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const required = need ?? this.needFor(booking, wagonLengths);
|
||||
let corridorMatched = false;
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
@@ -500,8 +503,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (this.fits(required, budget)) return schedule.id;
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
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');
|
||||
}
|
||||
@@ -631,6 +642,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? b.paymentDeadline.toISOString()
|
||||
: null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
priorityScore: Number(b.priorityScore ?? 0),
|
||||
freightType: b.freightType ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -719,6 +732,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? b.paymentDeadline.toISOString()
|
||||
: null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
priorityScore: Number(b.priorityScore ?? 0),
|
||||
freightType: b.freightType ?? null,
|
||||
fullyExecutedAt: b.fullyExecutedAt
|
||||
? b.fullyExecutedAt.toISOString()
|
||||
: null,
|
||||
@@ -1009,8 +1024,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (budget.wagons <= 0) {
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
if (budget.maxRemaining().wagons <= 0) {
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
return;
|
||||
}
|
||||
@@ -1019,6 +1034,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
|
||||
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
|
||||
// reservations trickle instead of landing in one pass (a reserve() throwing
|
||||
// mid-loop, e.g. schema drift, or a mis-synced capacity cap).
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` +
|
||||
`maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` +
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
@@ -1026,17 +1050,39 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
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)) {
|
||||
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
|
||||
);
|
||||
|
||||
if (!budget.fits(need, leg)) {
|
||||
if (isGov) {
|
||||
budget = await this.preemptForGovernment(
|
||||
const freed = await this.preemptForGovernment(
|
||||
scheduleId,
|
||||
need,
|
||||
leg,
|
||||
budget,
|
||||
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 {
|
||||
// Doesn't fit whole. A split-eligible import booking is offered the part
|
||||
// that fits in the remaining room (top-up path splits the boundary
|
||||
// booking, mirroring fillRouteDay); otherwise skip and try the next.
|
||||
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
|
||||
id: scheduleId,
|
||||
budget,
|
||||
armed,
|
||||
};
|
||||
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
|
||||
armed = cand.armed;
|
||||
continue;
|
||||
}
|
||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||
}
|
||||
}
|
||||
@@ -1049,11 +1095,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget = this.subtract(budget, need);
|
||||
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
|
||||
budget.subtract(need, leg);
|
||||
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);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
@@ -1106,8 +1152,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
|
||||
// Live per-schedule budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
@@ -1120,24 +1166,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingCapacity(
|
||||
schedule,
|
||||
limits,
|
||||
wagonLengths,
|
||||
);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
// The day pool covers every booking whose leg lies somewhere on one of the
|
||||
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
|
||||
// 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,
|
||||
);
|
||||
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
|
||||
// Batch fill trace: each train's caps + the day pool size at entry.
|
||||
this.logger.debug(
|
||||
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` +
|
||||
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
@@ -1146,20 +1199,42 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: this.needFor(booking, wagonLengths);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
|
||||
// First train (earliest departure) that fits this unit as-is.
|
||||
let target = trains.find((t) => this.fits(need, t.budget));
|
||||
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
||||
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);
|
||||
});
|
||||
|
||||
// Per-unit trace: chosen train + each train's remaining room on this leg.
|
||||
this.logger.debug(
|
||||
`[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`targetTrain=${target?.id ?? "none"} ` +
|
||||
`rooms=${trains
|
||||
.map((t) => {
|
||||
const leg = legOn(t);
|
||||
return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`;
|
||||
})
|
||||
.join(",")}`,
|
||||
);
|
||||
|
||||
if (!target && isGov) {
|
||||
// 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) {
|
||||
t.budget = await this.preemptForGovernment(
|
||||
const leg = legOn(t);
|
||||
if (!leg) continue;
|
||||
const freed = await this.preemptForGovernment(
|
||||
t.id,
|
||||
need,
|
||||
leg,
|
||||
t.budget,
|
||||
wagonLengths,
|
||||
);
|
||||
if (this.fits(need, t.budget)) {
|
||||
if (freed) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
@@ -1167,33 +1242,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// A consolidated pair is placed whole or not at all — never split.
|
||||
if (!isPair) {
|
||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||
// partial-capacity offer on the train with the most free wagons.
|
||||
const partialTarget = [...trains]
|
||||
.filter((t) => t.budget.wagons >= 1)
|
||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||
if (
|
||||
partialTarget &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
booking.contractKind === "GENERAL" &&
|
||||
this.splitService
|
||||
) {
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
partialTarget.id,
|
||||
partialTarget.budget,
|
||||
need,
|
||||
);
|
||||
if (offered) {
|
||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||
partialTarget.armed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fits no train whole. A split-eligible booking is offered the largest
|
||||
// part that fits on the train with the most free wagons on its leg (this
|
||||
// covers both "fits nowhere" and the boundary case where earlier bookings
|
||||
// already consumed most of the room). Consolidated pairs / government /
|
||||
// non-import never split — isSplitEligible guards that. Passing the live
|
||||
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
||||
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
|
||||
if (offered) continue;
|
||||
// Stays in the pool, retried next batch/window cycle.
|
||||
this.notifier.unplaced(booking, day);
|
||||
if (partner) this.notifier.unplaced(partner, day);
|
||||
@@ -1208,11 +1264,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget = this.subtract(target.budget, need);
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
}
|
||||
|
||||
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);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
@@ -1220,6 +1276,57 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
|
||||
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
|
||||
* neither shared wagon) and government bookings never split (they preempt).
|
||||
*/
|
||||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||
return (
|
||||
!isPair &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
|
||||
this.splitService != null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer the largest fitting part of a booking that does not fit any candidate
|
||||
* train whole, on the train with the most free wagons on the booking's leg.
|
||||
* Mutates the chosen candidate's budget + armed flag in place. Returns true when
|
||||
* an offer was opened (caller should `continue` past this unit), false otherwise.
|
||||
* Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg
|
||||
* is computed per candidate from the booking's yards, so callers pass their live
|
||||
* train entries and only leg-carrying trains are considered.
|
||||
*/
|
||||
private async maybeOfferPartial(
|
||||
booking: Booking,
|
||||
isPair: boolean,
|
||||
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
|
||||
need: Capacity,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSplitEligible(booking, isPair)) return false;
|
||||
const target = candidates
|
||||
.map((c) => {
|
||||
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
return leg ? { c, leg, room: c.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 (!target) return false;
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
target.c.id,
|
||||
target.room,
|
||||
need,
|
||||
);
|
||||
if (!offered) return false;
|
||||
target.c.budget.subtract(offered, target.leg);
|
||||
target.c.armed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer the largest fitting part of an over-capacity booking as a partial
|
||||
* (split-on-payment). Returns the capacity the offer consumes, or null when no
|
||||
@@ -1414,10 +1521,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
"Target schedule is not accepting bookings",
|
||||
);
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== booking.originYardId ||
|
||||
schedule.destinationStationId !== booking.destinationYardId
|
||||
) {
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const fromIdx = stops.indexOf(booking.originYardId);
|
||||
const toIdx = stops.indexOf(booking.destinationYardId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
|
||||
throw new BadRequestException(
|
||||
"Target schedule is not on the booking route",
|
||||
);
|
||||
@@ -1461,13 +1568,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// ---- intercity ride-along API ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Remaining capacity budget (wagons / weight / length) for a schedule, and
|
||||
* the per-booking need calculator — exposed for the intercity accept flow,
|
||||
* which reserves ride-along bookings onto import/export trains outside the
|
||||
* batch engine.
|
||||
* Remaining corridor capacity budget (per-edge wagons / weight / length) for
|
||||
* a schedule, and the per-booking need calculator — exposed for the intercity
|
||||
* accept flow, which reserves ride-along bookings onto import/export trains
|
||||
* 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<{
|
||||
budget: Capacity;
|
||||
budget: CorridorBudget;
|
||||
needFor: (booking: Booking) => Capacity;
|
||||
} | null> {
|
||||
const schedule =
|
||||
@@ -1477,7 +1585,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -1632,16 +1740,93 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||
* are covered. Empty when no fillable schedule exists for the group.
|
||||
*/
|
||||
private async corridorYardsForRouteDay(
|
||||
group: RouteDayGroup,
|
||||
): Promise<string[]> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: group.originYardId,
|
||||
destinationStationId: group.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: group.originYardId,
|
||||
destinationStationId: group.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const yards = new Set<string>();
|
||||
for (const schedule of corridor) {
|
||||
if (
|
||||
schedule.scheduledDepartureDate == null ||
|
||||
eatDay(schedule.scheduledDepartureDate) !== group.day
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const yardId of await this.stopsForSchedule(schedule)) {
|
||||
yards.add(yardId);
|
||||
}
|
||||
}
|
||||
return [...yards];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep bookings on a route-day whose operation request staff did NOT accept by
|
||||
* the time the window's document-review phase ends. They never reached
|
||||
* FULLY_EXECUTED, so they never enter the batch — expire them (customer must
|
||||
* rebook a new window). No reservation and no invoice exists yet at this stage,
|
||||
* so this is a lighter expiry than `expire()`: just flip status + notify, and
|
||||
* best-effort close any payable if one was issued early. Government/export are
|
||||
* excluded by the query.
|
||||
*/
|
||||
async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||||
if (corridorYards.length === 0) return;
|
||||
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
|
||||
corridorYards,
|
||||
group.day,
|
||||
);
|
||||
for (const booking of unaccepted) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
// Free the shipment day so the customer can rebook a fresh window.
|
||||
scheduledDate: null,
|
||||
} as never);
|
||||
// Close any payable issued before doc-review end (normally none — the invoice
|
||||
// is created at ops-accept, which by definition has not happened here).
|
||||
await this.billing
|
||||
.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID")
|
||||
.catch(() => undefined);
|
||||
this.notifier.expired(booking);
|
||||
this.logger.log(
|
||||
`Expired unaccepted booking ${booking.reference}:${booking.id} at doc-review end ` +
|
||||
`(${group.originYardId}->${group.destinationYardId} ${group.day})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||
* 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(
|
||||
scheduleId: string,
|
||||
need: Capacity,
|
||||
budget: Capacity,
|
||||
leg: CorridorLeg,
|
||||
budget: CorridorBudget,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
): Promise<boolean> {
|
||||
if (budget.fits(need, leg)) return true;
|
||||
const reservedCommercial = (
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||
).filter((b) => !b.isGovernment);
|
||||
@@ -1655,9 +1840,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
|
||||
);
|
||||
|
||||
let freed = budget;
|
||||
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.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
scheduleId,
|
||||
@@ -1680,9 +1872,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
});
|
||||
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 -----------------------------------------------------
|
||||
@@ -1790,22 +1982,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). */
|
||||
private async capacityLimits(
|
||||
locomotive: Locomotive,
|
||||
@@ -1883,37 +2059,68 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.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,
|
||||
limits: Capacity,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
): Promise<CorridorBudget> {
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const used = [...allocated, ...reserved].reduce<Capacity>(
|
||||
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
|
||||
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
|
||||
);
|
||||
return this.subtract(limits, used);
|
||||
for (const b of [...allocated, ...reserved]) {
|
||||
budget.subtract(
|
||||
this.needFor(b, wagonLengths),
|
||||
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||
);
|
||||
}
|
||||
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> {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const budget = await this.remainingBudget(
|
||||
schedule,
|
||||
{
|
||||
wagons: schedule.maxWagons ?? 0,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
wagonLengths,
|
||||
);
|
||||
const used =
|
||||
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
|
||||
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
|
||||
return (schedule.maxWagons ?? 0) - used;
|
||||
return budget.maxRemaining().wagons;
|
||||
}
|
||||
|
||||
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,101 @@
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
|
||||
/**
|
||||
* applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL
|
||||
* (both the parent contract row and the booking's denormalized copy) so the split
|
||||
* remainder can be rebooked. A GENERAL booking is left untouched.
|
||||
*/
|
||||
describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
||||
const bookingId = 'bk-1';
|
||||
const contractId = 'ct-1';
|
||||
const offerId = 'of-1';
|
||||
|
||||
const buildService = (bookingContractKind: 'ONE_TIME' | 'GENERAL') => {
|
||||
const offer = {
|
||||
id: offerId,
|
||||
bookingId,
|
||||
status: 'OFFERED',
|
||||
offeredWagons: 3,
|
||||
totalWagons: 5,
|
||||
offeredWeightTons: 30,
|
||||
offeredAmount: 300,
|
||||
offeredPricingBreakdown: {},
|
||||
offeredLines: null,
|
||||
} as unknown as BookingBatchOffer;
|
||||
|
||||
const bookingRepo = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: bookingId,
|
||||
contractId,
|
||||
contractKind: bookingContractKind,
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const contractRepo = { update: jest.fn().mockResolvedValue(undefined) };
|
||||
const offerRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(offer),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const containerRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
};
|
||||
const unitRepo = { find: jest.fn().mockResolvedValue([]), softDelete: jest.fn() };
|
||||
|
||||
const repoFor = (entity: unknown) => {
|
||||
if (entity === Booking) return bookingRepo;
|
||||
if (entity === Contract) return contractRepo;
|
||||
if (entity === BookingBatchOffer) return offerRepo;
|
||||
if (entity === BookingContainer) return containerRepo;
|
||||
if (entity === BookingContainerUnit) return unitRepo;
|
||||
return { find: jest.fn().mockResolvedValue([]), update: jest.fn() };
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: jest.fn(repoFor),
|
||||
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
|
||||
await fn({ getRepository: repoFor });
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new BookingSplitService(
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ expirePayable: jest.fn() } as never,
|
||||
{ payNowPartial: jest.fn() } as never,
|
||||
);
|
||||
return { service, bookingRepo, contractRepo };
|
||||
};
|
||||
|
||||
it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => {
|
||||
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
expect(bookingRepo.update).toHaveBeenCalledWith(
|
||||
bookingId,
|
||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||
);
|
||||
expect(contractRepo.update).toHaveBeenCalledWith(
|
||||
contractId,
|
||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a GENERAL booking untouched (no contract promotion)', async () => {
|
||||
const { service, contractRepo } = buildService('GENERAL');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
expect(contractRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { BillingService } from '../billing/billing.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import {
|
||||
BookingBatchOffer,
|
||||
OfferedLine,
|
||||
@@ -30,10 +31,11 @@ export interface SizedOffer {
|
||||
* pays, which is the act of accepting the split (applySplit). No payment →
|
||||
* offer expires and the booking stays whole.
|
||||
*
|
||||
* Only GENERAL-contract commercial bookings are offered partials: the remainder
|
||||
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
|
||||
* returns to the contract's quantity cap (derived live from booking_container
|
||||
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
||||
* any later window within contract validity.
|
||||
* any later window within contract validity. A ONE_TIME contract is promoted to
|
||||
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingSplitService {
|
||||
@@ -245,6 +247,25 @@ export class BookingSplitService {
|
||||
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||
} as never);
|
||||
|
||||
// A ONE_TIME contract permits a single active booking, which would block the
|
||||
// split remainder from ever being rebooked. Promote the parent contract (and
|
||||
// the booking's denormalized copy) to GENERAL so the leftover quantity draws
|
||||
// down against the cap like any general contract, within the same validity.
|
||||
const booking = await manager.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, contractId: true, contractKind: true },
|
||||
});
|
||||
if (booking?.contractKind === 'ONE_TIME') {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { contractKind: 'GENERAL' } as never);
|
||||
if (booking.contractId) {
|
||||
await manager
|
||||
.getRepository(Contract)
|
||||
.update(booking.contractId, { contractKind: 'GENERAL' } as never);
|
||||
}
|
||||
}
|
||||
|
||||
await manager
|
||||
.getRepository(BookingBatchOffer)
|
||||
.update(offer.id, { status: 'APPLIED' });
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { BookingWindowService } from './booking-window.service';
|
||||
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
/**
|
||||
* Window state-machine tests: exercise the real advanceImport transitions and the
|
||||
* concludeCycle reopen/done decision with mocked collaborators. Drives the exact
|
||||
* production phase logic (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → conclude) and
|
||||
* asserts the side effects the batch/settle/reopen flow depends on.
|
||||
*/
|
||||
describe('BookingWindowService — window state machine', () => {
|
||||
const scheduleId = 'sched-1';
|
||||
|
||||
let service: BookingWindowService;
|
||||
let batch: {
|
||||
setWindow: jest.Mock;
|
||||
processRouteDay: jest.Mock;
|
||||
expireUnacceptedForRouteDay: jest.Mock;
|
||||
settleDueReservations: jest.Mock;
|
||||
isScheduleFull: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
let updateMock: jest.Mock;
|
||||
|
||||
const cfg = {
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
windowOpenHour: 0, // 24h desk → reopen opens immediately
|
||||
windowCloseHour: 0,
|
||||
windowDurationHours: 1,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
reopenDelayMinutes: 0,
|
||||
};
|
||||
|
||||
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||
({
|
||||
id: scheduleId,
|
||||
direction: 'IMPORT',
|
||||
originStationId: 'yard-o',
|
||||
destinationStationId: 'yard-d',
|
||||
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
bookingCycleNo: 0,
|
||||
windowOpensAt: null,
|
||||
windowClosesAt: null,
|
||||
docReviewEndsAt: null,
|
||||
docReviewCompletedAt: null,
|
||||
paymentPhaseEndsAt: null,
|
||||
...over,
|
||||
}) as unknown as TrainSchedule;
|
||||
|
||||
const advanceImport = (s: TrainSchedule, now: Date): Promise<boolean> =>
|
||||
(service as unknown as {
|
||||
advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise<boolean>;
|
||||
}).advanceImport(s, cfg, now);
|
||||
const concludeCycle = (s: TrainSchedule, now: Date): Promise<void> =>
|
||||
(service as unknown as {
|
||||
concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise<void>;
|
||||
}).concludeCycle(s, cfg, now);
|
||||
|
||||
beforeEach(() => {
|
||||
updateMock = jest.fn().mockResolvedValue(undefined);
|
||||
batch = {
|
||||
setWindow: jest.fn().mockResolvedValue(undefined),
|
||||
processRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
||||
isScheduleFull: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
trainSchedulingService = {
|
||||
finalizeSchedule: jest.fn().mockResolvedValue(undefined),
|
||||
getWindowConfig: jest.fn().mockResolvedValue(cfg),
|
||||
};
|
||||
|
||||
service = new BookingWindowService(
|
||||
{ getRepository: () => ({ update: updateMock }) } as never,
|
||||
trainSchedulesRepository as never,
|
||||
batch as never,
|
||||
trainSchedulingService as never,
|
||||
{ directSend: jest.fn() } as never,
|
||||
{ notify: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('PRE_WINDOW → OPEN at windowOpensAt (opens the customer window)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
windowOpensAt: new Date('2026-07-01T00:00:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T00:00:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('OPEN');
|
||||
expect(s.bookingCycleNo).toBe(1);
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'OPEN');
|
||||
});
|
||||
|
||||
it('OPEN → DOC_REVIEW at windowClosesAt (closes booking, sets doc-review deadline)', async () => {
|
||||
const closesAt = new Date('2026-07-01T01:00:00.000Z');
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowClosesAt: closesAt,
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:00:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('DOC_REVIEW');
|
||||
expect(s.docReviewEndsAt).toEqual(new Date(closesAt.getTime() + 30 * 60_000));
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'CLOSED');
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
expect(s.paymentPhaseEndsAt).not.toBeNull();
|
||||
// Expiry sweep runs BEFORE the batch (unaccepted must not compete for capacity).
|
||||
expect(batch.expireUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
|
||||
expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
|
||||
const expireOrder = batch.expireUnacceptedForRouteDay.mock.invocationCallOrder[0];
|
||||
const batchOrder = batch.processRouteDay.mock.invocationCallOrder[0];
|
||||
expect(expireOrder).toBeLessThan(batchOrder);
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
|
||||
docReviewCompletedAt: new Date('2026-07-01T01:31:00.000Z'), // staff clicked done
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:31:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
});
|
||||
|
||||
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
// settleDueReservations runs (allocate paid / expire unpaid, then top-up).
|
||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(true);
|
||||
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:02.000Z'));
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
|
||||
expect(s.windowPhase).toBe('DONE');
|
||||
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
// departure well in the future so nextCycleOpensAt returns a real time.
|
||||
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
|
||||
});
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:03.000Z'));
|
||||
expect(s.windowPhase).toBe('PRE_WINDOW');
|
||||
expect(s.windowOpensAt).not.toBeNull();
|
||||
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
// departure already passed → nextCycleOpensAt returns null → finish.
|
||||
scheduledDepartureDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
});
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
|
||||
expect(s.windowPhase).toBe('DONE');
|
||||
});
|
||||
|
||||
it('no transition fires before its deadline (idempotent tick)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowClosesAt: new Date('2026-07-01T10:00:00.000Z'), // future
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:00:00.000Z'));
|
||||
expect(advanced).toBe(false);
|
||||
expect(s.windowPhase).toBe('OPEN');
|
||||
expect(batch.setWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -263,14 +263,19 @@ export class BookingWindowService implements OnModuleInit {
|
||||
) {
|
||||
const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000);
|
||||
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||
// Run the batch: priority fill over the route-day pool, reserving pay windows
|
||||
// (or allocating government) — skipped automatically for everyone who fits
|
||||
// is handled inside the fill (all fit → all reserved → all notified).
|
||||
await this.bookingBatchService.processRouteDay({
|
||||
const routeDay = {
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day: eatDay(schedule.scheduledDepartureDate),
|
||||
});
|
||||
};
|
||||
// Doc review is over: bookings staff never accepted (still pending) can no
|
||||
// longer make this train — expire them BEFORE the batch so they never
|
||||
// compete for capacity and never reach the pool.
|
||||
await this.bookingBatchService.expireUnacceptedForRouteDay(routeDay);
|
||||
// Run the batch: priority fill over the route-day pool, reserving pay windows
|
||||
// (or allocating government) — skipped automatically for everyone who fits
|
||||
// is handled inside the fill (all fit → all reserved → all notified).
|
||||
await this.bookingBatchService.processRouteDay(routeDay);
|
||||
this.logger.log(
|
||||
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
);
|
||||
|
||||
@@ -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 { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingBatchService, type Capacity } from './booking-batch.service';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
|
||||
/**
|
||||
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
|
||||
@@ -32,6 +32,7 @@ export class IntercityService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -52,13 +53,20 @@ export class IntercityService {
|
||||
return {
|
||||
scheduleId,
|
||||
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) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
const leg = capacity?.budget.legOf(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
return {
|
||||
...this.mapBooking(booking),
|
||||
need,
|
||||
fits: need && capacity ? fits(need, capacity.budget) : false,
|
||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => ({
|
||||
@@ -94,7 +102,7 @@ export class IntercityService {
|
||||
|
||||
const accepted: string[] = [];
|
||||
const rejected: Array<{ bookingId: string; reason: string }> = [];
|
||||
let budget = capacity.budget;
|
||||
const budget = capacity.budget;
|
||||
|
||||
for (const bookingId of bookingIds) {
|
||||
const booking = await this.dataSource
|
||||
@@ -110,45 +118,35 @@ export class IntercityService {
|
||||
continue;
|
||||
}
|
||||
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({
|
||||
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;
|
||||
}
|
||||
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
|
||||
budget = subtract(budget, need);
|
||||
budget.subtract(need, leg);
|
||||
accepted.push(bookingId);
|
||||
this.logger.log(
|
||||
`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
|
||||
* the train is physically at the booking's origin yard: either it has not
|
||||
* departed yet and the booking boards at the train's own origin, or the
|
||||
* latest recorded checkpoint is at the booking's origin yard.
|
||||
* Mark an accepted intercity booking's cargo as loaded. Delegates to the
|
||||
* shared per-booking journey flow (same checkpoint gating as import/export).
|
||||
*/
|
||||
async loadBooking(scheduleId: string, bookingId: string) {
|
||||
const { schedule, booking } = await this.getAcceptedBooking(
|
||||
scheduleId,
|
||||
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 };
|
||||
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
|
||||
return this.bookingJourneyService.loadBooking(scheduleId, bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,20 +154,8 @@ export class IntercityService {
|
||||
* requires the latest checkpoint to be at that yard. Completes the booking.
|
||||
*/
|
||||
async unloadBooking(scheduleId: string, bookingId: string) {
|
||||
const { schedule, booking } = await this.getAcceptedBooking(
|
||||
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');
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { status: 'COMPLETED' });
|
||||
return { bookingId, status: 'COMPLETED' as const };
|
||||
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
|
||||
return this.bookingJourneyService.unloadBooking(scheduleId, bookingId);
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
@@ -298,36 +284,6 @@ export class IntercityService {
|
||||
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) {
|
||||
return {
|
||||
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 { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
import { BookingJourneyService } from "./booking-journey.service";
|
||||
import { BookingWindowService } from "./booking-window.service";
|
||||
import { IntercityService } from "./intercity.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
@@ -60,6 +61,7 @@ export class TrainSchedulingController {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly bookingWindowService: BookingWindowService,
|
||||
private readonly intercityService: IntercityService,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly billingService: BillingService,
|
||||
) { }
|
||||
|
||||
@@ -432,6 +434,42 @@ export class TrainSchedulingController {
|
||||
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")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -30,8 +30,10 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingWindowService } from './booking-window.service';
|
||||
import { IntercityService } from './intercity.service';
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
@@ -51,6 +53,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
TrainCheckpointEvent,
|
||||
ImportDjiboutiOperation,
|
||||
BookingBatchOffer,
|
||||
WagonMovement,
|
||||
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
|
||||
Session,
|
||||
]),
|
||||
@@ -77,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
BookingWindowService,
|
||||
BookingSplitService,
|
||||
IntercityService,
|
||||
BookingJourneyService,
|
||||
],
|
||||
exports: [
|
||||
TrainSchedulingService,
|
||||
|
||||
@@ -155,6 +155,9 @@ describe('TrainSchedulingService', () => {
|
||||
htmlToPdfBuffer: jest.fn(),
|
||||
} as never,
|
||||
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
||||
{
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} as never, // bookingJourneyService
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
SchedulingStatus,
|
||||
TrainCheckpointKind,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
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 { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
@@ -113,6 +115,7 @@ import {
|
||||
eatDay,
|
||||
} from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
@@ -276,6 +279,7 @@ export class TrainSchedulingService {
|
||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
@@ -290,15 +294,26 @@ export class TrainSchedulingService {
|
||||
private async completeMilestonesForScheduleBookings(
|
||||
scheduleId: string,
|
||||
codes: string[],
|
||||
filter?: { originYardId?: string; destinationYardId?: string },
|
||||
): Promise<void> {
|
||||
if (!this.milestoneService || codes.length === 0) return;
|
||||
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(
|
||||
`SELECT tsb.booking_id
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||||
WHERE ${conditions.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
for (const { booking_id } of rows) {
|
||||
for (const code of codes) {
|
||||
@@ -1457,6 +1472,24 @@ export class TrainSchedulingService {
|
||||
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.
|
||||
await manager
|
||||
.getRepository(TrainSchedule)
|
||||
@@ -1488,18 +1521,24 @@ export class TrainSchedulingService {
|
||||
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||||
void this.emitWindowState(scheduleId);
|
||||
// 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') {
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||
// doc-trigger path no-ops it for import bookings.
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
schedule.direction === 'IMPORT'
|
||||
? 'DEPARTED_FROM_DJIBOUTI'
|
||||
: 'DEPARTED_TO_DJIBOUTI',
|
||||
]);
|
||||
void this.completeMilestonesForScheduleBookings(
|
||||
scheduleId,
|
||||
[
|
||||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||
// doc-trigger path no-ops it for import bookings.
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
schedule.direction === 'IMPORT'
|
||||
? 'DEPARTED_FROM_DJIBOUTI'
|
||||
: 'DEPARTED_TO_DJIBOUTI',
|
||||
],
|
||||
{ originYardId: schedule.originStationId },
|
||||
);
|
||||
}
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
@@ -2426,18 +2465,11 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = $2,
|
||||
scheduling_status = $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.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
|
||||
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
||||
);
|
||||
// Per-booking journey: bookings destined for the FINAL yard that the
|
||||
// operator didn't unload individually get their arrival stamped now as a
|
||||
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
|
||||
// their own unload (possibly already done while the train kept rolling).
|
||||
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
|
||||
|
||||
// Release every locomotive of the set (not just the legacy primary) and move it
|
||||
// to the destination yard where it physically arrived.
|
||||
@@ -2455,12 +2487,33 @@ export class TrainSchedulingService {
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: slot.physicalWagonId } });
|
||||
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, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
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.
|
||||
@@ -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') {
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
|
||||
]);
|
||||
void this.completeMilestonesForScheduleBookings(
|
||||
scheduleId,
|
||||
[schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
|
||||
{ destinationYardId: schedule.destinationStationId },
|
||||
);
|
||||
}
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
@@ -2635,17 +2692,26 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
if (
|
||||
bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
await (async () => {
|
||||
// Corridor-aware: a booking belongs on this train when its origin and
|
||||
// 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 (
|
||||
b.originYardId !== dto.originStationId ||
|
||||
b.destinationYardId !== dto.destinationStationId
|
||||
);
|
||||
})
|
||||
return bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
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) {
|
||||
@@ -2695,7 +2761,37 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
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]));
|
||||
fleetAvailability = computeFleetAvailability(
|
||||
demandPlan,
|
||||
@@ -2720,6 +2816,12 @@ export class TrainSchedulingService {
|
||||
containerWagonType,
|
||||
bulkWagonType,
|
||||
});
|
||||
this.stampSlotLegs(
|
||||
wagonPlan,
|
||||
fittingBookings,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
|
||||
violations.push(
|
||||
...(await this.validatePhysicalFleetForPlan(
|
||||
@@ -3047,6 +3149,7 @@ export class TrainSchedulingService {
|
||||
wagonTypeId: slot.wagonTypeId,
|
||||
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
|
||||
trainSetWagonId: slot.id,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
}));
|
||||
|
||||
const unpinnable = this.findUnpinnableWagonSlots(
|
||||
@@ -3100,6 +3203,7 @@ export class TrainSchedulingService {
|
||||
sequenceNo: slot.sequenceNo,
|
||||
wagonTypeId: slot.wagonTypeId,
|
||||
wagonTypeCode: slot.wagonTypeCode,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
})),
|
||||
wagons,
|
||||
targetScheduleId,
|
||||
@@ -3108,7 +3212,12 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private findUnpinnableWagonSlots(
|
||||
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
||||
slots: Array<{
|
||||
sequenceNo: number;
|
||||
wagonTypeId: string;
|
||||
wagonTypeCode: string;
|
||||
boardYardId?: string | null;
|
||||
}>,
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
originYardId: string,
|
||||
@@ -3136,22 +3245,35 @@ export class TrainSchedulingService {
|
||||
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(
|
||||
slot: { wagonTypeId: string },
|
||||
slot: { wagonTypeId: string; boardYardId?: string | null },
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
originYardId: string,
|
||||
assignedPhysicalIds: Set<string>,
|
||||
): Wagon | undefined {
|
||||
return wagons.find((wagon) => {
|
||||
const usable = (wagon: Wagon): boolean => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||
const pinnedOnSchedule = scheduleId
|
||||
? wagon.currentTrainScheduleId === scheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
||||
return wagon.currentYardId === originYardId;
|
||||
});
|
||||
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
|
||||
};
|
||||
// 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 {
|
||||
@@ -3303,6 +3425,42 @@ export class TrainSchedulingService {
|
||||
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(
|
||||
manager: EntityManager,
|
||||
trainSetId: string,
|
||||
@@ -3318,6 +3476,8 @@ export class TrainSchedulingService {
|
||||
lengthMeters: slot.lengthMeters,
|
||||
assignedWeightTons: slot.assignedWeightTons,
|
||||
status: 'PLANNED',
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
alightYardId: slot.alightYardId ?? null,
|
||||
}),
|
||||
);
|
||||
return manager.getRepository(TrainSetWagon).save(wagons);
|
||||
@@ -3752,14 +3912,16 @@ export class TrainSchedulingService {
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`,
|
||||
ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
[companyId],
|
||||
);
|
||||
// Nearest dispatch (departure) date first — the DISTINCT ON above forces a
|
||||
// per-row ordering, so re-sort the mapped rows by departure for the client.
|
||||
return rows
|
||||
.map((r) => this.mapBookingWindowRow(r))
|
||||
.sort((a, b) => {
|
||||
const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
|
||||
const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
|
||||
const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
return ta - tb;
|
||||
});
|
||||
}
|
||||
@@ -3801,7 +3963,7 @@ export class TrainSchedulingService {
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
[contractId],
|
||||
);
|
||||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||||
@@ -3839,7 +4001,7 @@ export class TrainSchedulingService {
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
...this.mapBookingWindowRow({
|
||||
@@ -4012,26 +4174,44 @@ export class TrainSchedulingService {
|
||||
|
||||
// How many wagons of that type the cargo needs.
|
||||
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.
|
||||
const availableByYard = new Map<string, number>();
|
||||
const availableAt = async (yardId: string): Promise<number> => {
|
||||
const cached = availableByYard.get(yardId);
|
||||
if (cached !== undefined) return cached;
|
||||
const counts = await this.countFleetAvailability(yardId);
|
||||
const n =
|
||||
counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
availableByYard.set(yardId, n);
|
||||
return n;
|
||||
};
|
||||
// TEMP (per request): wagon-availability filtering is DISABLED. A day is now
|
||||
// offered whenever a bookable schedule that day has remaining train capacity
|
||||
// — regardless of whether matching wagons are actually available at the
|
||||
// origin / boarding yard. This surfaces days even when no wagon is on hand.
|
||||
// Restore the block below to bring back the "enough matching wagons" gate.
|
||||
//
|
||||
// // AVAILABLE wagons of the required type, counted once per origin yard.
|
||||
// const availableByYard = new Map<string, number>();
|
||||
// const availableAt = async (yardId: string): Promise<number> => {
|
||||
// const cached = availableByYard.get(yardId);
|
||||
// if (cached !== undefined) return cached;
|
||||
// const counts = await this.countFleetAvailability(yardId);
|
||||
// const n =
|
||||
// counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
// availableByYard.set(yardId, n);
|
||||
// return n;
|
||||
// };
|
||||
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const hasCapacity =
|
||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||
if (!hasCapacity) continue;
|
||||
const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||
if (!enoughWagons) continue;
|
||||
// TEMP (per request): wagon-availability check commented out — see note
|
||||
// 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)
|
||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||
}
|
||||
@@ -4063,6 +4243,33 @@ export class TrainSchedulingService {
|
||||
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. */
|
||||
async existsOpenScheduleOnRouteDay(
|
||||
originYardId: string,
|
||||
|
||||
@@ -38,6 +38,13 @@ export type WagonPlanSlot = {
|
||||
assignedWeightTons: number;
|
||||
allocations: WagonAllocationRecord[];
|
||||
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 = {
|
||||
|
||||
@@ -55,6 +55,17 @@ export class TrainSetWagon extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
|
||||
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)
|
||||
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);
|
||||
}
|
||||
|
||||
@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')
|
||||
@FleetManage()
|
||||
@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 { InjectRepository } from '@nestjs/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 { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -74,8 +75,9 @@ export class WagonsService {
|
||||
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 previousYardId = wagon.currentYardId ?? null;
|
||||
Object.assign(wagon, dto);
|
||||
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
||||
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
|
||||
@@ -85,11 +87,40 @@ export class WagonsService {
|
||||
wagon.currentYard = null;
|
||||
}
|
||||
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
|
||||
// instead of the stale relation object loaded before the assign.
|
||||
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> {
|
||||
const wagon = await this.findById(id);
|
||||
await this.wagonRepo.remove(wagon);
|
||||
|
||||
@@ -340,6 +340,7 @@ export class SchedulingReadFacade {
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'ARRIVED_AT_DJIBOUTI',
|
||||
'ARRIVED_AT_PORT',
|
||||
'ARRIVED_AT_DESTINATION',
|
||||
|
||||
@@ -510,7 +510,7 @@ export class WarehouseInventoryService {
|
||||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||
|
||||
/** 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). */
|
||||
async arrivalQueue(): Promise<ArrivalQueueItem[]> {
|
||||
|
||||
@@ -196,7 +196,6 @@ async function ensureReferences(manager: any) {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
|
||||
@@ -138,7 +138,6 @@ async function main() {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
|
||||
@@ -214,7 +214,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 10,
|
||||
},
|
||||
|
||||
@@ -295,7 +295,6 @@ export class DemoBookingsSeeder {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
|
||||
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 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;
|
||||
// access is granted via the assigned position (positionKey) + PositionPermission.
|
||||
|
||||
@@ -136,7 +136,6 @@ export class PaidImportExportMileDemoSeeder {
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 11,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ const statusColorMap: Record<string, string> = {
|
||||
EXPIRED: "red",
|
||||
PAID: "edr-green",
|
||||
IN_TRANSIT: "cyan",
|
||||
ARRIVED: "teal",
|
||||
COMPLETED: "indigo",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Center, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { FileText, FolderOpen } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface LabeledFile {
|
||||
label: string;
|
||||
file: { id: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every document tied to a booking, in one tab: the customer/GL clearance
|
||||
* documents, the customs workflow files (declaration/duty/transit/Djibouti),
|
||||
* the duty-tax notice, and the final invoice + payment slip. All fetched from
|
||||
* the booking's clearance view (the only endpoint that surfaces booking files),
|
||||
* each with inline view + download.
|
||||
*/
|
||||
export function BookingDocumentsPanel({ bookingId }: { bookingId: string }) {
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: clearance, isLoading, isError } = useQuery({
|
||||
queryKey: ["clearance", bookingId],
|
||||
queryFn: () => bookingsService.getClearance(bookingId),
|
||||
});
|
||||
|
||||
const onDownload = (f: { id: string; name: string }) =>
|
||||
void downloadBookingFile(f.id, f.name);
|
||||
|
||||
// Uploaded customer + GL clearance documents (skip the not-yet-uploaded slots).
|
||||
const clearanceDocs = useMemo<
|
||||
Array<{ doc: Freight.ClearanceDocument; file: { id: string; name: string } }>
|
||||
>(
|
||||
() =>
|
||||
(clearance?.documents ?? [])
|
||||
.filter((d) => d.file)
|
||||
.map((d) => ({ doc: d, file: d.file! })),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const workflowFiles = useMemo(
|
||||
() => (clearance?.workflowFiles ?? []).filter((f) => f.file),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
// Duty notice + final invoice + payment slip — loose files that don't ride in
|
||||
// the documents/workflow arrays.
|
||||
const otherFiles = useMemo<LabeledFile[]>(() => {
|
||||
const rows: LabeledFile[] = [];
|
||||
const notice = clearance?.dutyAdvice?.noticeFile;
|
||||
if (notice) rows.push({ label: "Duty & tax notice", file: notice });
|
||||
const inv = clearance?.finalInvoice;
|
||||
if (inv?.invoiceFile)
|
||||
rows.push({ label: `Final invoice · ${inv.invoiceNumber}`, file: inv.invoiceFile });
|
||||
if (inv?.slipFile)
|
||||
rows.push({ label: "Final invoice payment slip", file: inv.slipFile });
|
||||
return rows;
|
||||
}, [clearance]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py={60}>
|
||||
<Group gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading documents…</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAny =
|
||||
clearanceDocs.length > 0 || workflowFiles.length > 0 || otherFiles.length > 0;
|
||||
|
||||
if (isError || !hasAny) {
|
||||
return (
|
||||
<SectionCard icon={FolderOpen} title="Documents" accent="edr-green">
|
||||
<Center py={28}>
|
||||
<Stack align="center" gap={6}>
|
||||
<FolderOpen size={26} color="var(--mantine-color-gray-5)" />
|
||||
<Text fw={600}>No documents yet</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={360}>
|
||||
{isError
|
||||
? "Couldn’t load this booking’s documents."
|
||||
: "Documents attached to this booking will appear here as they’re uploaded."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{clearanceDocs.length > 0 && (
|
||||
<SectionCard icon={FileText} title="Clearance documents" accent="edr-green">
|
||||
<Stack gap={8}>
|
||||
{clearanceDocs.map(({ doc, file }) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={doc.fileKey}
|
||||
label={`${doc.label}${doc.uploadedBy === "gl" ? " · GL" : ""}`}
|
||||
file={file}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{workflowFiles.length > 0 && (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={workflowFiles}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{otherFiles.length > 0 && (
|
||||
<SectionCard icon={FileText} title="Invoices & notices" accent="edr-green">
|
||||
<Stack gap={8}>
|
||||
{otherFiles.map((row) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={row.file.id}
|
||||
label={row.label}
|
||||
file={row.file}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<Box>{viewer}</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./booking-detail.styles";
|
||||
export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./BookingDocumentsPanel";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
|
||||
@@ -630,6 +630,20 @@ function DocReviewCard({
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -646,7 +646,9 @@ function FinalInvoiceStep({
|
||||
const invoice = clearance.finalInvoice ?? null;
|
||||
const paid = invoice?.status === "PAID";
|
||||
|
||||
if (!clearance.offloaded && !invoice) {
|
||||
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
|
||||
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
|
||||
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
@@ -740,7 +742,7 @@ function FinalInvoiceStep({
|
||||
) : canDjAct && bookingId ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Cargo offloaded — send the final invoice to the customer.
|
||||
Send the final invoice to the customer if post-arrival charges apply (optional).
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
|
||||
@@ -150,16 +150,19 @@ export default function GlCreateBookingForm() {
|
||||
[bookingWindows],
|
||||
);
|
||||
|
||||
// Soonest future window across all routes, used for the "next window" notice.
|
||||
// Next future window across all routes, used for the "next window" notice —
|
||||
// the train dispatching soonest among those not yet open, matching the
|
||||
// departure-date ordering of the window cards.
|
||||
const nextWindow = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return (bookingWindows ?? [])
|
||||
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.windowOpensAt!).getTime() -
|
||||
new Date(b.windowOpensAt!).getTime(),
|
||||
)[0];
|
||||
.sort((a, b) => {
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
||||
})[0];
|
||||
}, [bookingWindows]);
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
|
||||
@@ -21,7 +21,28 @@ import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||
import { api } from "@/services/api";
|
||||
import type { StaffBookingWindow } from "@/types/trainScheduling";
|
||||
|
||||
/**
|
||||
* The fields a window card needs. Structural so both `StaffBookingWindow`
|
||||
* (all-lanes staff feed) and `BookingWindow` (contract-scoped feed, which
|
||||
* carries no train number) satisfy it.
|
||||
*/
|
||||
interface WindowRow {
|
||||
scheduleId: string;
|
||||
trainNumber?: string | null;
|
||||
direction: string | null;
|
||||
windowPhase: string | null;
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
}
|
||||
|
||||
/** All window times are communicated in East Africa Time. */
|
||||
const TZ = "Africa/Addis_Ababa";
|
||||
@@ -46,7 +67,7 @@ function fmtTime(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function windowLabel(w: StaffBookingWindow): string {
|
||||
function windowLabel(w: WindowRow): string {
|
||||
if (w.windowOpensAt && w.windowClosesAt) {
|
||||
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime(
|
||||
w.windowClosesAt,
|
||||
@@ -64,7 +85,7 @@ function windowLabel(w: StaffBookingWindow): string {
|
||||
* between refetches announces what comes next rather than the bare "Expired".
|
||||
*/
|
||||
function phaseCountdown(
|
||||
w: StaffBookingWindow,
|
||||
w: WindowRow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
switch (w.windowPhase) {
|
||||
case "PRE_WINDOW":
|
||||
@@ -104,19 +125,18 @@ function phaseCountdown(
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop windows whose booking window (or the train itself) has already passed. */
|
||||
function isPast(w: StaffBookingWindow): boolean {
|
||||
const now = Date.now();
|
||||
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
|
||||
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
|
||||
// Still live while in a post-close staff phase (doc review / payment).
|
||||
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
|
||||
if (departs != null && departs <= now) return true;
|
||||
if (closes != null && closes <= now) return true;
|
||||
return false;
|
||||
/**
|
||||
* Drop windows the SERVER considers finished — keyed off windowPhase, never the
|
||||
* client clock. The server query already excludes terminal / departed rows;
|
||||
* comparing `Date.now()` here only re-introduced clock skew that made a card
|
||||
* vanish and reappear on refresh. Trust the server phase (live-patched over the
|
||||
* socket) instead.
|
||||
*/
|
||||
function isPast(w: WindowRow): boolean {
|
||||
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
|
||||
}
|
||||
|
||||
function WindowCard({ w }: { w: StaffBookingWindow }) {
|
||||
function WindowCard({ w }: { w: WindowRow }) {
|
||||
const cd = phaseCountdown(w);
|
||||
const open = w.isOpenNow;
|
||||
const isImport = w.direction === "IMPORT";
|
||||
@@ -218,21 +238,45 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface GlUpcomingWindowsSectionProps {
|
||||
/**
|
||||
* Scope the card to one contract: only windows on that contract's routes
|
||||
* (and therefore its import/export direction) are shown. Omit for the
|
||||
* all-lanes staff feed on the clearance queue.
|
||||
*/
|
||||
contractId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All announced booking windows (import cycles + export FCFS) across every lane,
|
||||
* shown to GL ET on the clearance queue as a paged carousel — three lanes per
|
||||
* page, arrows to flip. Mirrors the customer's portal "Booking Windows" card.
|
||||
* Hidden when nothing is pending.
|
||||
* Announced booking windows (import cycles + export FCFS) as a paged carousel —
|
||||
* three lanes per page, arrows to flip. Without `contractId` it shows every
|
||||
* lane (GL ET clearance queue); with `contractId` it shows only the windows
|
||||
* matching that contract's routes/direction (clearance detail page). Mirrors
|
||||
* the customer's portal "Booking Windows" card. Hidden when nothing is pending.
|
||||
*/
|
||||
export function GlUpcomingWindowsSection() {
|
||||
export function GlUpcomingWindowsSection({
|
||||
contractId,
|
||||
}: GlUpcomingWindowsSectionProps = {}) {
|
||||
// Live pushes flip cards the moment the window engine transitions a phase;
|
||||
// the 60s poll below stays only as a fallback.
|
||||
useBookingWindowSocket();
|
||||
const { data, isLoading } = useQuery(
|
||||
api.trainScheduling.allBookingWindows.queryOptions({
|
||||
const allLanes = useQuery({
|
||||
...api.trainScheduling.allBookingWindows.queryOptions({
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
enabled: !contractId,
|
||||
});
|
||||
const contractLanes = useQuery({
|
||||
...api.trainScheduling.contractBookingWindows.queryOptions({
|
||||
input: { contractId: contractId ?? "" },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
enabled: Boolean(contractId),
|
||||
});
|
||||
const data: WindowRow[] | undefined = contractId
|
||||
? contractLanes.data
|
||||
: allLanes.data;
|
||||
const isLoading = contractId ? contractLanes.isLoading : allLanes.isLoading;
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const windows = useMemo(() => {
|
||||
@@ -241,13 +285,13 @@ export function GlUpcomingWindowsSection() {
|
||||
);
|
||||
// Canceled schedules are retired to windowPhase='DONE' server-side, so the
|
||||
// guard above already excludes them; they never reach the upcoming list.
|
||||
// Open lanes first, then by opening time.
|
||||
// Order by the train's dispatch (departure) date, nearest first. Open-now
|
||||
// breaks ties on the same departure.
|
||||
return rows.sort((a, b) => {
|
||||
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
if (openDiff !== 0) return openDiff;
|
||||
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
|
||||
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
|
||||
return at - bt;
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -270,7 +314,9 @@ export function GlUpcomingWindowsSection() {
|
||||
Booking windows
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed">
|
||||
Import and export booking windows across all lanes (EAT)
|
||||
{contractId
|
||||
? "Booking windows on this contract's routes (EAT)"
|
||||
: "Import and export booking windows across all lanes (EAT)"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
@@ -727,9 +727,9 @@ function ImportT1UploadStep({
|
||||
);
|
||||
}
|
||||
|
||||
const departed = Boolean(t1.trainDepartedAt);
|
||||
const canUpload =
|
||||
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
|
||||
// Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia
|
||||
// closes/accepts the T1.
|
||||
const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -769,10 +769,6 @@ function ImportT1UploadStep({
|
||||
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
|
||||
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 ? (
|
||||
<StepStatus
|
||||
done={false}
|
||||
|
||||
@@ -177,6 +177,7 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
||||
APPROVED: "cyan",
|
||||
PAID: "edr-green",
|
||||
IN_TRANSIT: "blue",
|
||||
ARRIVED: "teal",
|
||||
COMPLETED: "indigo",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
|
||||
@@ -33,7 +33,9 @@ const FleetRecordActions = ({
|
||||
const isVehicle = config.slug === "vehicles";
|
||||
const showHistory =
|
||||
Boolean(onHistory) &&
|
||||
(config.slug === "drivers" || config.slug === "vehicles");
|
||||
(config.slug === "drivers" ||
|
||||
config.slug === "vehicles" ||
|
||||
config.slug === "wagons");
|
||||
|
||||
const handleDetail = () => {
|
||||
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,557 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Container,
|
||||
Crown,
|
||||
Hourglass,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardScheduleDetail,
|
||||
} from "@/types/trainScheduling";
|
||||
import { WindowPhasePill } from "./batchVisuals";
|
||||
|
||||
/**
|
||||
* Priority Tracking tab — live, glanceable ranking of every booking on this
|
||||
* schedule in the exact order the batch engine boards them (government first,
|
||||
* then rule-engine priority score, then oldest). Bookings above the train's
|
||||
* wagon-capacity line render as "selected" (green), below it as the waiting
|
||||
* list; during the PAYMENT phase selected bookings show a live pay-window
|
||||
* countdown. Purely presentational — data comes from the batch-board detail
|
||||
* response the page already polls (+ socket-invalidates).
|
||||
*/
|
||||
|
||||
type Props = {
|
||||
data: BatchBoardScheduleDetail;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
};
|
||||
|
||||
const STATE_STYLE: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
|
||||
SELECTED_FOR_BATCH: { label: "Selected · pay now", color: "orange", icon: Clock },
|
||||
READY: { label: "Ready", color: "teal", icon: Hourglass },
|
||||
WAITING: { label: "Paid · waiting slot", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
|
||||
/** States that occupy a wagon slot on this train (i.e. are "in" the batch). */
|
||||
const OCCUPIES_SLOT: BatchBoardBookingState[] = [
|
||||
"ALLOCATED",
|
||||
"SELECTED_FOR_BATCH",
|
||||
"WAITING",
|
||||
];
|
||||
|
||||
const cardVar = (color: string, shade: number) =>
|
||||
`var(--mantine-color-${color}-${shade})`;
|
||||
|
||||
/** Highest score across the ranked pool → used to scale the priority mini-bar. */
|
||||
function maxScore(bookings: BatchBoardBookingDetail[]): number {
|
||||
return bookings.reduce((m, b) => Math.max(m, b.priorityScore ?? 0), 0);
|
||||
}
|
||||
|
||||
function FreightIcon({ type }: { type: string | null }) {
|
||||
const Icon = type === "BULK" ? Boxes : Container;
|
||||
return (
|
||||
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
|
||||
<Icon size={13} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One ranked booking row rendered as a card, colored by its batch state. */
|
||||
function RankedCard({
|
||||
rank,
|
||||
booking,
|
||||
scoreMax,
|
||||
phase,
|
||||
isPayPhase,
|
||||
}: {
|
||||
rank: number;
|
||||
booking: BatchBoardBookingDetail;
|
||||
scoreMax: number;
|
||||
phase: string | null;
|
||||
isPayPhase: boolean;
|
||||
}) {
|
||||
const style = STATE_STYLE[booking.state];
|
||||
const Icon = style.icon;
|
||||
const selected = booking.state === "SELECTED_FOR_BATCH";
|
||||
const allocated = booking.state === "ALLOCATED";
|
||||
const expired = booking.state === "EXPIRED";
|
||||
// Green surface for the winners (allocated + selected); muted for the rest.
|
||||
const surfaceColor = allocated
|
||||
? "edr-green"
|
||||
: selected
|
||||
? "edr-green"
|
||||
: expired
|
||||
? "red"
|
||||
: "gray";
|
||||
const scorePct =
|
||||
scoreMax > 0 ? Math.max(4, Math.round((booking.priorityScore / scoreMax) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="md"
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: cardVar(surfaceColor, allocated || selected ? 4 : 2),
|
||||
background:
|
||||
allocated || selected
|
||||
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 60%)`
|
||||
: expired
|
||||
? cardVar("red", 0)
|
||||
: "var(--mantine-color-white)",
|
||||
opacity: expired ? 0.72 : 1,
|
||||
transition: "background 200ms ease, border-color 200ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
|
||||
{/* Rank medallion */}
|
||||
<ThemeIcon
|
||||
size={34}
|
||||
radius="xl"
|
||||
variant={rank <= 3 ? "filled" : "light"}
|
||||
color={
|
||||
booking.isGovernment
|
||||
? "grape"
|
||||
: rank <= 3
|
||||
? "edr-green"
|
||||
: "gray"
|
||||
}
|
||||
style={{ flexShrink: 0, fontWeight: 800 }}
|
||||
>
|
||||
{booking.isGovernment ? (
|
||||
<Crown size={16} />
|
||||
) : (
|
||||
<Text fw={800} size="sm">
|
||||
{rank}
|
||||
</Text>
|
||||
)}
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<FreightIcon type={booking.freightType} />
|
||||
{booking.isGovernment ? (
|
||||
<Tooltip label="Government — boards first" withArrow>
|
||||
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
|
||||
<Crown size={11} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
|
||||
{/* Priority score with a mini strength bar */}
|
||||
<Tooltip
|
||||
label={`Priority score ${booking.priorityScore}${booking.isGovernment ? " + government bonus" : ""}`}
|
||||
withArrow
|
||||
>
|
||||
<Stack gap={2} align="flex-end" w={92}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Trophy size={12} color={cardVar("edr-green", 6)} />
|
||||
<Text fw={800} size="sm" c="edr-green.7">
|
||||
{booking.priorityScore}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={scorePct}
|
||||
size="xs"
|
||||
color="edr-green"
|
||||
w={92}
|
||||
radius="xl"
|
||||
/>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
|
||||
{/* Wagons */}
|
||||
<Group gap={4} wrap="nowrap" w={58} justify="flex-end">
|
||||
<TrainFront size={13} color={cardVar("gray", 6)} />
|
||||
<Text fw={700} size="sm">
|
||||
{booking.wagons}w
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* State chip / pay countdown */}
|
||||
<Box w={168} style={{ textAlign: "right" }}>
|
||||
{selected && isPayPhase && booking.paymentDeadline ? (
|
||||
<CountdownTimer
|
||||
deadline={booking.paymentDeadline}
|
||||
label="Pay in"
|
||||
expiredText="Window closed"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Group gap={5} justify="flex-end" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={style.color}
|
||||
>
|
||||
<Icon size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={600} c={`${style.color}.7`}>
|
||||
{style.label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
{/* phase hint only used for the a11y title; keeps `phase` referenced */}
|
||||
<span hidden aria-hidden>
|
||||
{phase}
|
||||
</span>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** The capacity cut line drawn between "in the batch" and "waiting list". */
|
||||
function CapacityDivider({ used, max }: { used: number; max: number | null }) {
|
||||
const full = max != null && used >= max;
|
||||
return (
|
||||
<Group gap="xs" my={4} wrap="nowrap">
|
||||
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
|
||||
<Layers size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={700} c="orange.7">
|
||||
Capacity line{max != null ? ` · ${used}/${max} wagons` : ` · ${used} wagons`}
|
||||
{full ? " · FULL" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function PriorityTrackingTab({ data, bookings }: Props) {
|
||||
const phase = data.windowPhase;
|
||||
const isPayPhase = phase === "PAYMENT";
|
||||
|
||||
// Rank exactly as the batch engine does: government first, then priority score
|
||||
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
|
||||
// backend uses). The board already returns them in this order, but re-sort
|
||||
// defensively so the tab is correct even if the source order ever changes.
|
||||
const ranked = useMemo(() => {
|
||||
const time = (b: BatchBoardBookingDetail) =>
|
||||
b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return [...bookings].sort((a, b) => {
|
||||
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
||||
if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore;
|
||||
return time(a) - time(b);
|
||||
});
|
||||
}, [bookings]);
|
||||
|
||||
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
|
||||
// maxWagons is not on the board DTO (capacity is length/weight-based), so the
|
||||
// capacity line shows the wagons currently committed rather than a hard cap.
|
||||
const maxWagons: number | null = null;
|
||||
|
||||
// Split the ranking at the capacity line: cumulative wagons of slot-occupying
|
||||
// bookings (allocated + selected + paid-waiting) up to the train's wagon cap.
|
||||
const capUsed = useMemo(
|
||||
() =>
|
||||
ranked
|
||||
.filter((b) => OCCUPIES_SLOT.includes(b.state))
|
||||
.reduce((sum, b) => sum + b.wagons, 0),
|
||||
[ranked],
|
||||
);
|
||||
|
||||
// Group for the lane layout.
|
||||
const lanes = useMemo(() => {
|
||||
const inBatch = ranked.filter((b) => OCCUPIES_SLOT.includes(b.state));
|
||||
const waiting = ranked.filter(
|
||||
(b) => b.state === "READY" || b.state === "PENDING_CONTRACT",
|
||||
);
|
||||
const expired = ranked.filter((b) => b.state === "EXPIRED");
|
||||
return { inBatch, waiting, expired };
|
||||
}, [ranked]);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return (
|
||||
<Paper radius="lg" withBorder p="xl">
|
||||
<Group justify="center" gap="sm">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size="lg">
|
||||
<ListOrdered size={18} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No bookings on this schedule yet.</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
let rankNo = 0;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header: phase + capacity meter */}
|
||||
<Paper radius="lg" withBorder p="lg">
|
||||
<Group justify="space-between" wrap="wrap" gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||||
<Trophy size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>Priority ranking</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Government first, then rule-engine score, then earliest booked.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{phase ? (
|
||||
<WindowPhasePill phase={phase} cycleNo={data.bookingCycleNo} />
|
||||
) : null}
|
||||
{isPayPhase && data.paymentPhaseEndsAt ? (
|
||||
<CountdownTimer
|
||||
deadline={data.paymentPhaseEndsAt}
|
||||
label="Payment window"
|
||||
expiredText="Window closed"
|
||||
size="md"
|
||||
/>
|
||||
) : phase === "DOC_REVIEW" && data.docReviewEndsAt ? (
|
||||
<CountdownTimer
|
||||
deadline={data.docReviewEndsAt}
|
||||
label="Doc review ends"
|
||||
expiredText="Review over"
|
||||
size="md"
|
||||
/>
|
||||
) : phase === "OPEN" && data.windowClosesAt ? (
|
||||
<CountdownTimer
|
||||
deadline={data.windowClosesAt}
|
||||
label="Booking closes"
|
||||
expiredText="Closed"
|
||||
size="md"
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Capacity meter */}
|
||||
<Box mt="md">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Wagon capacity used
|
||||
</Text>
|
||||
<Text size="xs" fw={700}>
|
||||
{data.capacity.allocatedWagons} allocated ·{" "}
|
||||
{capUsed} in batch
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress.Root size="lg" radius="xl">
|
||||
<Progress.Section
|
||||
value={
|
||||
capUsed > 0
|
||||
? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100)
|
||||
: 0
|
||||
}
|
||||
color="edr-green"
|
||||
/>
|
||||
<Progress.Section
|
||||
value={
|
||||
capUsed > 0
|
||||
? Math.min(
|
||||
100,
|
||||
((capUsed - data.capacity.allocatedWagons) / capUsed) * 100,
|
||||
)
|
||||
: 0
|
||||
}
|
||||
color="orange"
|
||||
/>
|
||||
</Progress.Root>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Phase banner explaining what's happening now */}
|
||||
<PhaseBanner phase={phase} />
|
||||
|
||||
{/* IN THE BATCH (green winners) — ranked */}
|
||||
{lanes.inBatch.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="edr-green">
|
||||
<CheckCircle2 size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
In the batch{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({lanes.inBatch.length})
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{lanes.inBatch.map((b) => {
|
||||
rankNo += 1;
|
||||
return (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={rankNo}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<CapacityDivider used={capUsed} max={maxWagons} />
|
||||
|
||||
{/* WAITING LIST — ranked, below the line */}
|
||||
{lanes.waiting.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="blue">
|
||||
<Hourglass size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
Waiting list{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({lanes.waiting.length}) — next in line if a slot frees up
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{lanes.waiting.map((b) => {
|
||||
rankNo += 1;
|
||||
return (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={rankNo}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* EXPIRED */}
|
||||
{lanes.expired.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="red">
|
||||
<XCircle size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm" c="red.7">
|
||||
Expired{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({lanes.expired.length}) — missed the payment window
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{lanes.expired.map((b) => (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={0}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Contextual banner describing the current window phase in plain language. */
|
||||
function PhaseBanner({ phase }: { phase: string | null }) {
|
||||
const meta: Record<string, { color: string; text: string; icon: typeof Clock }> = {
|
||||
OPEN: {
|
||||
color: "edr-green",
|
||||
icon: Clock,
|
||||
text: "Booking window OPEN — new bookings are ranked live as they arrive and get accepted.",
|
||||
},
|
||||
DOC_REVIEW: {
|
||||
color: "yellow",
|
||||
icon: Hourglass,
|
||||
text: "Document review — staff accept/reject; un-accepted bookings expire when review ends, then the batch runs.",
|
||||
},
|
||||
PAYMENT: {
|
||||
color: "blue",
|
||||
icon: Clock,
|
||||
text: "Payment window — selected bookings must pay before their countdown ends; unpaid slots pass to the waiting list.",
|
||||
},
|
||||
PRE_WINDOW: {
|
||||
color: "gray",
|
||||
icon: Hourglass,
|
||||
text: "Window not open yet — bookings are pre-ranked and will compete when it opens.",
|
||||
},
|
||||
CLOSED_FOR_DAY: {
|
||||
color: "gray",
|
||||
icon: Hourglass,
|
||||
text: "Window closed for the day — reopens for the next cycle if the train isn't full.",
|
||||
},
|
||||
DONE: {
|
||||
color: "gray",
|
||||
icon: CheckCircle2,
|
||||
text: "Booking cycles finished for this train.",
|
||||
},
|
||||
};
|
||||
const m = phase ? meta[phase] : null;
|
||||
if (!m) return null;
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Paper
|
||||
radius="md"
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
background: cardVar(m.color, 0),
|
||||
borderColor: cardVar(m.color, 2),
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={m.color} radius="md">
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={500} c={`${m.color}.8`}>
|
||||
{m.text}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriorityTrackingTab;
|
||||
@@ -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`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
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) =>
|
||||
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||
INTERCITY_ACCEPT: (id: string) =>
|
||||
|
||||
@@ -15,11 +15,56 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||
|
||||
// The two carousel window lists share the MyBookingWindow-shaped row and can be
|
||||
// patched in place. The batch board is a richer, differently-shaped view, so it
|
||||
// stays on a (debounced) invalidate.
|
||||
const WINDOW_ACTIONS = new Set(["all-booking-windows", "contractBookingWindows"]);
|
||||
|
||||
/** Shape shared by both carousel window lists (all-lanes + contract-scoped). */
|
||||
interface WindowRow {
|
||||
scheduleId: string;
|
||||
windowPhase: string | null;
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
}
|
||||
|
||||
function isWindowKey(key: readonly unknown[]): boolean {
|
||||
return key[0] === "train-scheduling" && WINDOW_ACTIONS.has(String(key[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to live booking-window pushes for staff. Every phase transition
|
||||
* the window engine applies invalidates the GL windows carousel and the batch
|
||||
* board, so both flip the moment the backend does — polling stays only as a
|
||||
* fallback.
|
||||
* Fold a server phase push onto a cached window row, recomputing isOpenNow the
|
||||
* same way the server does (phase OPEN + status OPEN) so live-patched state can
|
||||
* never disagree with a fresh REST fetch on refresh.
|
||||
*/
|
||||
function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent): T {
|
||||
return {
|
||||
...row,
|
||||
windowPhase: event.phase,
|
||||
bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus,
|
||||
bookingCycleNo: event.bookingCycleNo,
|
||||
isOpenNow: event.phase === "OPEN" && event.bookingWindowStatus === "OPEN",
|
||||
windowOpensAt: event.windowOpensAt,
|
||||
windowClosesAt: event.windowClosesAt,
|
||||
docReviewEndsAt: event.docReviewEndsAt,
|
||||
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
|
||||
departureDate: event.scheduledDepartureDate ?? row.departureDate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to live booking-window pushes for staff. A phase transition carries
|
||||
* the schedule's full new state; we fold it straight into the carousel window
|
||||
* lists with setQueriesData rather than invalidating — same rationale as the
|
||||
* portal hook (no per-push refetch storm; live + refreshed state agree, killing
|
||||
* the refresh-jump). The batch board is a different-shaped view, so it keeps a
|
||||
* debounced invalidate, as do pushes for schedules not present in any list.
|
||||
*/
|
||||
export function useBookingWindowSocket(enabled: boolean = true) {
|
||||
const qc = useQueryClient();
|
||||
@@ -47,19 +92,60 @@ export function useBookingWindowSocket(enabled: boolean = true) {
|
||||
console.debug("[booking-windows] socket disconnected:", reason),
|
||||
);
|
||||
|
||||
socket.on(
|
||||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||
(_event: BookingWindowPhaseEvent) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["train-scheduling", "all-booking-windows"],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
// Coalesce the batch-board refresh (and the unknown-schedule fallback) so a
|
||||
// burst of pushes triggers at most one invalidation per window.
|
||||
let refetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleRefetch = (includeWindowLists: boolean) => {
|
||||
if (refetchTimer) return;
|
||||
refetchTimer = setTimeout(() => {
|
||||
refetchTimer = null;
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
});
|
||||
if (includeWindowLists) {
|
||||
void qc.invalidateQueries({
|
||||
predicate: (q) => isWindowKey(q.queryKey),
|
||||
});
|
||||
}
|
||||
}, 800);
|
||||
};
|
||||
|
||||
socket.on(
|
||||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||
(event: BookingWindowPhaseEvent) => {
|
||||
let patchedSomewhere = false;
|
||||
|
||||
qc.setQueriesData<WindowRow[]>(
|
||||
{ predicate: (q) => isWindowKey(q.queryKey) },
|
||||
(rows) => {
|
||||
if (!rows) return rows;
|
||||
let changed = false;
|
||||
const next = rows.map((row) => {
|
||||
if (row.scheduleId !== event.scheduleId) return row;
|
||||
changed = true;
|
||||
patchedSomewhere = true;
|
||||
return applyEvent(row, event);
|
||||
});
|
||||
return changed ? next : rows;
|
||||
},
|
||||
);
|
||||
|
||||
// Refresh the batch-board DETAIL for the schedule that transitioned so the
|
||||
// Priority Tracking tab reranks + updates its countdowns immediately (the
|
||||
// detail is a different shape from the list — invalidate, don't patch).
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
|
||||
});
|
||||
|
||||
// Always refresh the batch board (different shape, not patched). When the
|
||||
// schedule wasn't in any window list either, refresh those too so a newly
|
||||
// announced window surfaces. Both debounced — no per-push stampede.
|
||||
scheduleRefetch(!patchedSomewhere);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (refetchTimer) clearTimeout(refetchTimer);
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
};
|
||||
|
||||
@@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "In Transit",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
ARRIVED: {
|
||||
label: "Arrived",
|
||||
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Completed",
|
||||
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",
|
||||
stage: 4,
|
||||
},
|
||||
ARRIVED: {
|
||||
title: "Arrived",
|
||||
description: "Cargo unloaded at its destination yard.",
|
||||
color: "text-emerald-600",
|
||||
stage: 4,
|
||||
},
|
||||
COMPLETED: {
|
||||
title: "Completed",
|
||||
description: "Booking fulfilled.",
|
||||
@@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [
|
||||
{
|
||||
key: "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: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
@@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT"],
|
||||
statuses: ["PAID", "IN_TRANSIT", "ARRIVED"],
|
||||
},
|
||||
{ label: "Done", statuses: ["COMPLETED"] },
|
||||
] as const;
|
||||
|
||||
@@ -39,7 +39,6 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
booking.serviceType?.label ??
|
||||
booking.serviceType?.name ??
|
||||
booking.serviceType?.code,
|
||||
serviceTypeBonus: booking.serviceType?.priorityBonusPoints ?? 0,
|
||||
trainScheduleId: booking.trainScheduleId ?? null,
|
||||
isGovernment: booking.isGovernment ?? false,
|
||||
governmentInstitution: booking.governmentInstitution ?? null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
BookingDocumentsPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
@@ -142,14 +144,17 @@ export default function BookingRequestDetailPage() {
|
||||
// A general contract drives an "Orders" tab: each drawdown order spawns a
|
||||
// child booking that staff manage (clearance/approval) independently.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
const showTabs = showClearanceTab || isGeneralContract;
|
||||
// The Documents tab is always available — every booking can accrue clearance,
|
||||
// customs-workflow, invoice or notice files — so the tab bar always renders.
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const activeTab =
|
||||
requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: requestedTab === "orders" && isGeneralContract
|
||||
? "orders"
|
||||
: "overview";
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -187,10 +192,10 @@ export default function BookingRequestDetailPage() {
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused */}
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused.
|
||||
The Documents tab is always present, so the tab bar always renders. */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{showTabs ? (
|
||||
<Tabs
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="pills"
|
||||
@@ -217,6 +222,12 @@ export default function BookingRequestDetailPage() {
|
||||
Customer clearance
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FolderOpen size={16} />}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
@@ -238,10 +249,10 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
@@ -199,6 +200,10 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{/* Windows on this contract's routes/direction only — tells GL ET when
|
||||
it can actually create the booking without checking the schedule board. */}
|
||||
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
|
||||
|
||||
{bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
|
||||
@@ -18,14 +18,11 @@ import {
|
||||
HardDrive,
|
||||
Layers,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -33,9 +30,7 @@ import { api } from "@/services/api";
|
||||
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
|
||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -43,7 +38,6 @@ export default function FileUploadSettingsPage() {
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.fileUploadSettings.list.queryOptions(),
|
||||
);
|
||||
const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions());
|
||||
|
||||
const fileUploadSettings = useMemo(
|
||||
() => (Array.isArray(data) ? data : []),
|
||||
@@ -178,6 +172,8 @@ export default function FileUploadSettingsPage() {
|
||||
headerClassName,
|
||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||
},
|
||||
// Settings are seeded/fixed — staff may only update a setting's fields,
|
||||
// not create, edit, or delete the settings themselves.
|
||||
cell: ({ row }) => {
|
||||
const setting = row.original;
|
||||
return (
|
||||
@@ -191,33 +187,12 @@ export default function FileUploadSettingsPage() {
|
||||
Fields
|
||||
</Button>
|
||||
</ManageFileUploadFieldsDialog>
|
||||
|
||||
<EditFileUploadSettingDialog mode="edit" setting={setting}>
|
||||
<ActionIcon variant="default" aria-label="Edit setting">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</EditFileUploadSettingDialog>
|
||||
|
||||
<DeleteFileUploadSettingDialog
|
||||
settingLabel={setting.label}
|
||||
settingCode={setting.code}
|
||||
onConfirm={() => deleteMutation.mutate({ id: setting.id })}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
color="red"
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label="Delete setting"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</DeleteFileUploadSettingDialog>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [deleteMutation]);
|
||||
}, []);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() {
|
||||
<PageHeader
|
||||
title="File upload settings"
|
||||
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
|
||||
action={
|
||||
<EditFileUploadSettingDialog mode="create">
|
||||
<Button leftSection={<Plus size={18} />}>New setting</Button>
|
||||
</EditFileUploadSettingDialog>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
@@ -286,7 +256,7 @@ export default function FileUploadSettingsPage() {
|
||||
emptyMessage={
|
||||
query.trim()
|
||||
? "No file upload settings match your search."
|
||||
: 'No file upload settings yet. Click "New setting" to add one.'
|
||||
: "No file upload settings configured."
|
||||
}
|
||||
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -585,12 +586,20 @@ const FleetResourcePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
record={historyTarget}
|
||||
/>
|
||||
) : (
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [
|
||||
{ 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 = [
|
||||
{ label: "Line staff", value: "LINE_STAFF" },
|
||||
{ label: "Director", value: "DIRECTOR" },
|
||||
@@ -181,6 +187,7 @@ const CURRENCIES = [
|
||||
const PRIORITY_CONFIG_TYPES = [
|
||||
{ label: "Wagon count", value: "WAGON" },
|
||||
{ label: "Payment currency", value: "CURRENCY" },
|
||||
{ label: "Customs clearance", value: "CUSTOMS" },
|
||||
];
|
||||
|
||||
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
|
||||
@@ -304,7 +311,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
slug: "priority-configs",
|
||||
label: "Priority Rules",
|
||||
category: "rules",
|
||||
subtitle: "Wagon-count and payment-currency scoring rules",
|
||||
subtitle: "Wagon-count, payment-currency, and customs scoring rules",
|
||||
searchPlaceholder: "Search priority rules...",
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
@@ -331,7 +338,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
|
||||
placeholder: "Select a currency",
|
||||
hideWhen: { field: "type", equals: ["WAGON"] },
|
||||
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
|
||||
},
|
||||
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
|
||||
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
|
||||
@@ -351,7 +358,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
codeColumn("code"),
|
||||
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
|
||||
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
@@ -361,7 +367,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
|
||||
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
|
||||
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
@@ -431,7 +436,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
Weight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -55,6 +56,8 @@ import {
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab";
|
||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||
import { BookingsManager } from "./BookingsManager";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
@@ -578,9 +581,23 @@ export default function BatchScheduleDetailPage() {
|
||||
api.trainScheduling.batchBoardDetail.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 30_000,
|
||||
// Poll fast while a window cycle is actively moving (open / doc-review /
|
||||
// payment) so the priority ranking + pay countdowns stay live; back off to
|
||||
// 30s once the cycle is idle (pre-window / closed / done).
|
||||
refetchInterval: (query) => {
|
||||
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
|
||||
?.windowPhase;
|
||||
return phase === "OPEN" ||
|
||||
phase === "DOC_REVIEW" ||
|
||||
phase === "PAYMENT"
|
||||
? 5_000
|
||||
: 30_000;
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Keep the board in sync with server-pushed window-phase transitions too
|
||||
// (invalidates the batch-board list + patches window carousels).
|
||||
useBookingWindowSocket(Boolean(scheduleId));
|
||||
const runAllocation = useMutation(
|
||||
api.trainScheduling.runAllocation.mutationOptions(),
|
||||
);
|
||||
@@ -735,6 +752,13 @@ export default function BatchScheduleDetailPage() {
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="priority"
|
||||
leftSection={<Trophy size={14} />}
|
||||
>
|
||||
Priority Tracking{" "}
|
||||
{allBookings.length > 0 && `(${allBookings.length})`}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="composition">
|
||||
Train Composition{" "}
|
||||
{scheduleDetailQuery.data?.trainSet?.wagons &&
|
||||
@@ -1095,6 +1119,10 @@ export default function BatchScheduleDetailPage() {
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="priority" pt="lg">
|
||||
<PriorityTrackingTab data={data} bookings={allBookings} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="composition" pt="lg">
|
||||
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
|
||||
<Group align="stretch" gap="md" wrap="nowrap">
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
@@ -1131,6 +1132,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
|
||||
{scheduleId ? (
|
||||
<IntercityRideAlongPanel
|
||||
scheduleId={scheduleId}
|
||||
|
||||
@@ -180,6 +180,7 @@ import {
|
||||
wagonService,
|
||||
type Wagon,
|
||||
type WagonListFilters,
|
||||
type WagonMovementRecord,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -593,6 +594,40 @@ export const api = {
|
||||
() => 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<
|
||||
{ scheduleId: string },
|
||||
import("@/types/trainScheduling").IntercityCandidatesResult
|
||||
@@ -1493,6 +1528,13 @@ export const api = {
|
||||
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<
|
||||
{ wagonId: string; trainId: string; sequenceNumber?: number },
|
||||
Wagon
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
AssignBookingsPayload,
|
||||
BookingLoadResult,
|
||||
BookingUnloadResult,
|
||||
CompositionRemovalEntry,
|
||||
UnassignedBookingsResponse,
|
||||
CreateTrainSchedulePayload,
|
||||
@@ -35,6 +37,7 @@ import type {
|
||||
UploadImportDjiboutiDocumentPayload,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
YardWorkResult,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
interface BookingReferenceDataResponse {
|
||||
@@ -330,6 +333,35 @@ export const trainSchedulingService = {
|
||||
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 (
|
||||
scheduleId: string,
|
||||
): Promise<IntercityCandidatesResult> => {
|
||||
|
||||
@@ -37,6 +37,27 @@ export interface WagonListFilters {
|
||||
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 = {
|
||||
getAll: (filters: WagonListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -49,6 +70,8 @@ export const wagonService = {
|
||||
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
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}`),
|
||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
||||
|
||||
@@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
"CANCELLED",
|
||||
@@ -206,7 +207,7 @@ export interface BookingDetail {
|
||||
company?: BookingNamedRef & Partial<BookingCompany>;
|
||||
originYard?: BookingNamedRef;
|
||||
destinationYard?: BookingNamedRef;
|
||||
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
|
||||
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
|
||||
cargoType?: BookingNamedRef;
|
||||
shippingLine?: BookingNamedRef;
|
||||
bookingContainers?: BookingContainerLine[];
|
||||
@@ -238,7 +239,6 @@ export interface BookingListRow {
|
||||
priorityScore: number;
|
||||
schedulingStatus?: string;
|
||||
serviceTypeLabel?: string;
|
||||
serviceTypeBonus?: number;
|
||||
trainScheduleId?: string | null;
|
||||
isGovernment?: boolean;
|
||||
governmentInstitution?: string | null;
|
||||
|
||||
@@ -118,6 +118,7 @@ export type CustomerBookingStatus =
|
||||
| "APPROVED"
|
||||
| "PAID"
|
||||
| "IN_TRANSIT"
|
||||
| "ARRIVED"
|
||||
| "COMPLETED"
|
||||
| "REJECTED"
|
||||
| "CANCELLED";
|
||||
|
||||
@@ -225,6 +225,10 @@ export interface BatchBoardBooking {
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
/** Rule-engine priority score used to rank the batch (higher = boards first). */
|
||||
priorityScore: number;
|
||||
/** CONTAINER | BULK — for the priority-tracking visuals. */
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,6 +363,8 @@ export interface BookingWindow {
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
@@ -794,3 +800,52 @@ export interface IntercityAcceptResult {
|
||||
rejected: Array<{ bookingId: string; reason: string }>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
|
||||
function getAuthToken(): string | undefined {
|
||||
return document.cookie
|
||||
@@ -20,11 +21,52 @@ function getAuthToken(): string | undefined {
|
||||
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||
|
||||
// Both window lists live under this key prefix (myBookingWindows +
|
||||
// contractBookingWindows/*), so one predicate patches every cached list.
|
||||
const WINDOW_KEY_PREFIX = ["train-scheduling"] as const;
|
||||
const WINDOW_ACTIONS = new Set(["myBookingWindows", "contractBookingWindows"]);
|
||||
|
||||
/**
|
||||
* Fold a server phase push onto a cached window row. `isOpenNow` is recomputed
|
||||
* exactly as the server's mapBookingWindowRow does (phase OPEN + status OPEN) so
|
||||
* the live-patched state can never disagree with what a fresh REST fetch returns
|
||||
* on refresh — both come from the same server timestamps, not the client clock.
|
||||
*/
|
||||
function applyEvent(
|
||||
row: MyBookingWindow,
|
||||
event: BookingWindowPhaseEvent,
|
||||
): MyBookingWindow {
|
||||
return {
|
||||
...row,
|
||||
windowPhase: event.phase,
|
||||
bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus,
|
||||
bookingCycleNo: event.bookingCycleNo,
|
||||
isOpenNow:
|
||||
event.phase === "OPEN" && event.bookingWindowStatus === "OPEN",
|
||||
windowOpensAt: event.windowOpensAt,
|
||||
windowClosesAt: event.windowClosesAt,
|
||||
docReviewEndsAt: event.docReviewEndsAt,
|
||||
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
|
||||
departureDate: event.scheduledDepartureDate ?? row.departureDate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to live booking-window pushes. Every phase transition the window
|
||||
* engine applies (open, doc review, payment, reopen, done) invalidates the
|
||||
* cached window lists, so the home-page "Booking Windows" card flips the
|
||||
* moment the backend does — the 60s poll remains only as a fallback.
|
||||
* engine applies (open, doc review, payment, reopen, done) carries the schedule's
|
||||
* full new state; we fold it straight into the cached window lists with
|
||||
* setQueriesData rather than invalidating.
|
||||
*
|
||||
* Why not invalidate: at ~200 concurrent users a namespace-wide broadcast made
|
||||
* every client refetch two heavy window queries on every schedule transition —
|
||||
* an O(users × schedules) stampede that lagged the whole population. Patching the
|
||||
* cache in place means a push costs each client one array map, no network. It
|
||||
* also fixes the refresh-jump: the live state and a post-refresh REST fetch now
|
||||
* derive isOpenNow/phase from the same server fields, so they agree.
|
||||
*
|
||||
* A push for a schedule not present in any cached list (a brand-new window) can't
|
||||
* be patched in — those fall back to a debounced invalidate so the new row still
|
||||
* appears, without the storm.
|
||||
*/
|
||||
export function useBookingWindowSocket(enabled: boolean) {
|
||||
const qc = useQueryClient();
|
||||
@@ -52,19 +94,59 @@ export function useBookingWindowSocket(enabled: boolean) {
|
||||
console.debug("[booking-windows] socket disconnected:", reason),
|
||||
);
|
||||
|
||||
// Coalesce the "unknown schedule → refetch" fallback so a burst of pushes
|
||||
// for new schedules triggers at most one invalidation per window.
|
||||
let refetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleRefetch = () => {
|
||||
if (refetchTimer) return;
|
||||
refetchTimer = setTimeout(() => {
|
||||
refetchTimer = null;
|
||||
void qc.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [prefix, action] = q.queryKey as unknown[];
|
||||
return prefix === WINDOW_KEY_PREFIX[0] && WINDOW_ACTIONS.has(String(action));
|
||||
},
|
||||
});
|
||||
}, 800);
|
||||
};
|
||||
|
||||
socket.on(
|
||||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||
(_event: BookingWindowPhaseEvent) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["train-scheduling", "myBookingWindows"],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["train-scheduling", "contractBookingWindows"],
|
||||
});
|
||||
(event: BookingWindowPhaseEvent) => {
|
||||
let patchedSomewhere = false;
|
||||
|
||||
qc.setQueriesData<MyBookingWindow[]>(
|
||||
{
|
||||
predicate: (q) => {
|
||||
const [prefix, action] = q.queryKey as unknown[];
|
||||
return (
|
||||
prefix === WINDOW_KEY_PREFIX[0] &&
|
||||
WINDOW_ACTIONS.has(String(action))
|
||||
);
|
||||
},
|
||||
},
|
||||
(rows) => {
|
||||
if (!rows) return rows;
|
||||
let changed = false;
|
||||
const next = rows.map((row) => {
|
||||
if (row.scheduleId !== event.scheduleId) return row;
|
||||
changed = true;
|
||||
patchedSomewhere = true;
|
||||
return applyEvent(row, event);
|
||||
});
|
||||
return changed ? next : rows;
|
||||
},
|
||||
);
|
||||
|
||||
// The schedule wasn't in any cached list — a newly announced window (or a
|
||||
// lane the client hasn't fetched). Fall back to a debounced refetch so it
|
||||
// surfaces, without the per-push stampede that patching avoids.
|
||||
if (!patchedSomewhere) scheduleRefetch();
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (refetchTimer) clearTimeout(refetchTimer);
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
};
|
||||
|
||||
@@ -17,13 +17,15 @@ export const ActivityRow = memo(function ActivityRow({
|
||||
const verb =
|
||||
booking.status === "IN_TRANSIT"
|
||||
? "departed"
|
||||
: booking.status === "COMPLETED"
|
||||
? "delivered"
|
||||
: booking.status === "PENDING_APPROVAL"
|
||||
? "quote ready"
|
||||
: booking.status === "SUBMITTED"
|
||||
? "submitted for review"
|
||||
: "created";
|
||||
: booking.status === "ARRIVED"
|
||||
? "arrived"
|
||||
: booking.status === "COMPLETED"
|
||||
? "delivered"
|
||||
: booking.status === "PENDING_APPROVAL"
|
||||
? "quote ready"
|
||||
: booking.status === "SUBMITTED"
|
||||
? "submitted for review"
|
||||
: "created";
|
||||
|
||||
return (
|
||||
<Group
|
||||
|
||||
@@ -188,16 +188,15 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
}: UpcomingWindowsSectionProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// Open lanes first, then by opening time — the ones the customer can act on
|
||||
// lead the carousel.
|
||||
// Order by the train's dispatch (departure) date, nearest first. Open-now
|
||||
// breaks ties on the same departure so an actionable lane leads.
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...windows].sort((a, b) => {
|
||||
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
if (openDiff !== 0) return openDiff;
|
||||
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
|
||||
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
|
||||
return at - bt;
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
}),
|
||||
[windows],
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export const ACTIVE_STATUSES = [
|
||||
"SUBMITTED",
|
||||
"PENDING_APPROVAL",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
];
|
||||
|
||||
export interface StageConfig {
|
||||
@@ -346,6 +347,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
||||
badgeDot: "edr-green.5",
|
||||
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: {
|
||||
stage: 4,
|
||||
icon: CheckCircle2,
|
||||
|
||||
@@ -118,7 +118,9 @@ export function ReadonlyBookingView({
|
||||
const canAssignCustomerTruck =
|
||||
booking.paymentStatus === "PAID" &&
|
||||
usesCustomerTruck &&
|
||||
["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status);
|
||||
["PAID", "IN_TRANSIT", "ARRIVED", "COMPLETED", "TRUCK_ASSIGNED"].includes(
|
||||
status,
|
||||
);
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
|
||||
@@ -66,10 +66,12 @@ export function StatusHero({
|
||||
}) {
|
||||
const status = booking.status;
|
||||
const stage = resolveStage(booking);
|
||||
// The Arrival stage has no booking status of its own — it lights up from the
|
||||
// train's ARRIVED state, so the headline is overridden here.
|
||||
// Legacy bookings never reach the ARRIVED status — they light up the Arrival
|
||||
// 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 =
|
||||
stage === ARRIVAL_STAGE
|
||||
stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE
|
||||
? {
|
||||
title: "Train arrived at destination",
|
||||
description:
|
||||
|
||||
@@ -54,12 +54,13 @@ export const PROGRESS_STAGES = [
|
||||
statuses: ["EXPIRED", "IN_TRANSIT"],
|
||||
},
|
||||
{
|
||||
// No booking status maps here: the booking stays IN_TRANSIT until
|
||||
// delivery, so this stage lights up from the assigned train's own status
|
||||
// ARRIVED: cargo unloaded at the booking's own destination yard (segment
|
||||
// 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.
|
||||
label: "Arrival",
|
||||
icon: MapPin,
|
||||
statuses: [],
|
||||
statuses: ["ARRIVED"],
|
||||
},
|
||||
{
|
||||
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:
|
||||
* a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its
|
||||
* train has ARRIVED the tracker advances to the Arrival stage.
|
||||
* a booking with per-booking journey data reaches ARRIVED when it is unloaded
|
||||
* 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: {
|
||||
status: string;
|
||||
@@ -177,6 +180,12 @@ export const STATUS_MAP: Record<
|
||||
description: "Your shipment is currently moving through the rail network.",
|
||||
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: {
|
||||
title: "Operation request under review",
|
||||
description:
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
const TRACKABLE_STATUSES = new Set([
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"DELIVERED",
|
||||
]);
|
||||
@@ -83,7 +84,7 @@ const STATUS_FILTERS = [
|
||||
statuses:
|
||||
"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: "closed",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Clock,
|
||||
Flag,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
PackageX,
|
||||
RefreshCw,
|
||||
Train,
|
||||
@@ -15,11 +16,14 @@ import { api } from "@/services/api";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
bookingJourneyState,
|
||||
bookingLegRange,
|
||||
bookingShipmentStatusLabel,
|
||||
checkpointKindLabel,
|
||||
corridorProgress,
|
||||
isArrived,
|
||||
isDispatched,
|
||||
shipmentStatusLabel,
|
||||
type BookingJourneyState,
|
||||
} from "./trackingStages";
|
||||
|
||||
const GREEN = "#0EA371";
|
||||
@@ -70,6 +74,7 @@ export function ShipmentTrackingModal({
|
||||
trainNumber={data?.trainNumber ?? null}
|
||||
status={data?.scheduleStatus ?? null}
|
||||
currentSequenceNo={data?.currentSequenceNo ?? -1}
|
||||
journey={data ? bookingJourneyState(data) : null}
|
||||
onClose={onClose}
|
||||
onRefresh={() => refetch()}
|
||||
refreshing={isFetching}
|
||||
@@ -95,6 +100,7 @@ export function ShipmentTrackingModal({
|
||||
) : data ? (
|
||||
<Stack gap={26}>
|
||||
<SummaryBar data={data} />
|
||||
<BookingJourneyLine data={data} />
|
||||
<Corridor data={data} />
|
||||
<CheckpointFeed data={data} />
|
||||
</Stack>
|
||||
@@ -111,6 +117,7 @@ function Header({
|
||||
trainNumber,
|
||||
status,
|
||||
currentSequenceNo,
|
||||
journey,
|
||||
onClose,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
@@ -119,6 +126,7 @@ function Header({
|
||||
trainNumber: string | null;
|
||||
status: Freight.TrainScheduleStatus | null;
|
||||
currentSequenceNo: number;
|
||||
journey: BookingJourneyState;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
@@ -171,7 +179,11 @@ function Header({
|
||||
</Group>
|
||||
|
||||
<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}>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
@@ -223,12 +235,17 @@ function IconButton({
|
||||
function HeaderStatusPill({
|
||||
status,
|
||||
currentSequenceNo,
|
||||
journey,
|
||||
}: {
|
||||
status: Freight.TrainScheduleStatus | null;
|
||||
currentSequenceNo: number;
|
||||
journey: BookingJourneyState;
|
||||
}) {
|
||||
const arrived = isArrived(status);
|
||||
const moving = isDispatched(status);
|
||||
// The booking's own journey wins: a sub-corridor booking can be unloaded
|
||||
// (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
|
||||
? "rgba(14,163,113,0.22)"
|
||||
: moving
|
||||
@@ -254,7 +271,7 @@ function HeaderStatusPill({
|
||||
}}
|
||||
/>
|
||||
<Text fz="12px" fw={700} c="#fff">
|
||||
{shipmentStatusLabel(status, currentSequenceNo)}
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
@@ -263,7 +280,10 @@ function HeaderStatusPill({
|
||||
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
|
||||
|
||||
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 }> = [
|
||||
{
|
||||
label: "Departed",
|
||||
@@ -271,7 +291,11 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
|
||||
},
|
||||
{
|
||||
label: arrived ? "Arrived" : "Est. arrival",
|
||||
value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
|
||||
value: fmtTime(
|
||||
(journey === "arrived" ? data.arrivedAt : null) ??
|
||||
data.actualArrivalAt ??
|
||||
data.scheduledArrivalAt,
|
||||
),
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
||||
@@ -326,6 +410,14 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
||||
const current = data.currentSequenceNo;
|
||||
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.
|
||||
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
|
||||
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 isCurrent = !arrived && i === current;
|
||||
const isLast = i === stations.length - 1;
|
||||
const onLeg = !leg || (i >= leg.start && i <= leg.end);
|
||||
const cp = checkpointBySeq.get(s.sequenceNo);
|
||||
return (
|
||||
<StationNode
|
||||
@@ -424,6 +517,7 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) {
|
||||
isCurrent={isCurrent}
|
||||
isEndpoint={i === 0 || isLast}
|
||||
arrivedHere={isLast && arrived}
|
||||
dimmed={!onLeg}
|
||||
time={cp ? fmtTime(cp.occurredAt) : null}
|
||||
align={i === 0 ? "left" : isLast ? "right" : "center"}
|
||||
/>
|
||||
@@ -441,6 +535,7 @@ function StationNode({
|
||||
isCurrent,
|
||||
isEndpoint,
|
||||
arrivedHere,
|
||||
dimmed,
|
||||
time,
|
||||
align,
|
||||
}: {
|
||||
@@ -449,6 +544,8 @@ function StationNode({
|
||||
isCurrent: boolean;
|
||||
isEndpoint: boolean;
|
||||
arrivedHere: boolean;
|
||||
/** Station lies outside the booking's own leg — render muted. */
|
||||
dimmed: boolean;
|
||||
time: string | null;
|
||||
align: "left" | "center" | "right";
|
||||
}) {
|
||||
@@ -462,6 +559,7 @@ function StationNode({
|
||||
flex: isEndpoint ? "0 0 auto" : 1,
|
||||
minWidth: 0,
|
||||
maxWidth: 120,
|
||||
opacity: dimmed ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -488,8 +586,8 @@ function StationNode({
|
||||
</Box>
|
||||
<Text
|
||||
fz="11.5px"
|
||||
fw={reached ? 700 : 600}
|
||||
c={reached ? INK : "#9AA8B5"}
|
||||
fw={!dimmed && reached ? 700 : 600}
|
||||
c={dimmed ? "#9AA8B5" : reached ? INK : "#9AA8B5"}
|
||||
mt={8}
|
||||
ta={align}
|
||||
truncate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
const { TrainScheduleStatus } = Freight;
|
||||
const { BookingStatus, TrainScheduleStatus } = Freight;
|
||||
|
||||
export function isArrived(
|
||||
status?: Freight.TrainScheduleStatus | null,
|
||||
@@ -50,6 +50,65 @@ export function corridorProgress(
|
||||
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. */
|
||||
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
|
||||
switch (kind) {
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
|
||||
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
/** All window times are communicated in East Africa Time. */
|
||||
const TZ = "Africa/Addis_Ababa";
|
||||
/** Cards visible per carousel page. */
|
||||
const PER_PAGE = 3;
|
||||
|
||||
function fmtDay(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: TZ,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: TZ,
|
||||
});
|
||||
}
|
||||
|
||||
/** "Thu, 10 Jul · 08:00 – 11:00 EAT" (or a phase label when times are unset). */
|
||||
function windowLabel(w: MyBookingWindow): string {
|
||||
if (w.windowOpensAt && w.windowClosesAt) {
|
||||
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime(
|
||||
w.windowClosesAt,
|
||||
)} EAT`;
|
||||
}
|
||||
if (w.windowOpensAt) {
|
||||
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
|
||||
}
|
||||
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The countdown for whichever phase the window is currently in, mirroring the
|
||||
* home dashboard's Booking Windows card. `expiredText` names the NEXT step so a
|
||||
* deadline that lapses between refetches announces what comes next rather than
|
||||
* the bare "Expired".
|
||||
*/
|
||||
function phaseCountdown(
|
||||
w: MyBookingWindow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
switch (w.windowPhase) {
|
||||
case "PRE_WINDOW":
|
||||
return w.windowOpensAt
|
||||
? {
|
||||
label: "Booking opens in",
|
||||
deadline: w.windowOpensAt,
|
||||
expiredText: "Booking opening now…",
|
||||
}
|
||||
: null;
|
||||
case "OPEN":
|
||||
return w.windowClosesAt
|
||||
? {
|
||||
label: "Window closes in",
|
||||
deadline: w.windowClosesAt,
|
||||
expiredText: "Document review starting…",
|
||||
}
|
||||
: null;
|
||||
case "DOC_REVIEW":
|
||||
return w.docReviewEndsAt
|
||||
? {
|
||||
label: "Document review ends in",
|
||||
deadline: w.docReviewEndsAt,
|
||||
expiredText: "Payment starting…",
|
||||
}
|
||||
: null;
|
||||
case "PAYMENT":
|
||||
return w.paymentPhaseEndsAt
|
||||
? {
|
||||
label: "Payment due in",
|
||||
deadline: w.paymentPhaseEndsAt,
|
||||
expiredText: "Payment window closing…",
|
||||
}
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop windows the SERVER considers finished. Keyed off the server's windowPhase
|
||||
* — never the client clock. The server query already excludes terminal
|
||||
* (DONE / CLOSED_FOR_DAY) and departed rows; comparing `Date.now()` against the
|
||||
* row's timestamps here only re-introduced clock skew, which made a card vanish
|
||||
* on one machine and reappear after refresh. So we trust the phase the server
|
||||
* sends (live-patched over the socket) and let it drive visibility.
|
||||
*/
|
||||
function isPast(w: MyBookingWindow): boolean {
|
||||
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
|
||||
}
|
||||
|
||||
function WindowCard({ w }: { w: MyBookingWindow }) {
|
||||
const cd = phaseCountdown(w);
|
||||
const open = w.isOpenNow;
|
||||
const isImport = w.direction === "IMPORT";
|
||||
|
||||
return (
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
height: "100%",
|
||||
border: `1px solid ${open ? "#CDEBDD" : BORDER}`,
|
||||
background: open
|
||||
? "linear-gradient(160deg, #F4FBF7 0%, #FFFFFF 85%)"
|
||||
: "#FFFFFF",
|
||||
boxShadow: open ? "0 2px 10px rgba(10,111,77,0.10)" : "none",
|
||||
}}
|
||||
>
|
||||
<Stack gap={8} h="100%" justify="space-between">
|
||||
<Box>
|
||||
<Group justify="space-between" wrap="nowrap" gap={8}>
|
||||
{w.direction ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={isImport ? "blue" : "teal"}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{isImport ? "Import" : "Export"}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Badge
|
||||
variant={open ? "filled" : "light"}
|
||||
color={open ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{open
|
||||
? "Open now"
|
||||
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={10}>
|
||||
<Text fz={15} fw={700} style={{ color: INK }} truncate>
|
||||
{w.origin ?? "—"}
|
||||
</Text>
|
||||
<ArrowRight size={14} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={15} fw={700} style={{ color: INK }} truncate>
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={8}>
|
||||
<CalendarClock size={13} color={MUTED} style={{ flexShrink: 0 }} />
|
||||
<Text fz={12} style={{ color: MUTED }} truncate>
|
||||
{windowLabel(w)}
|
||||
</Text>
|
||||
</Group>
|
||||
{w.departureDate ? (
|
||||
<Text fz={12} style={{ color: MUTED }}>
|
||||
Departs {fmtDay(w.departureDate)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{cd ? (
|
||||
<Box
|
||||
px={10}
|
||||
py={6}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: open ? "rgba(10,111,77,0.08)" : "#F8FAFC",
|
||||
}}
|
||||
>
|
||||
<CountdownTimer
|
||||
deadline={cd.deadline}
|
||||
label={cd.label}
|
||||
expiredText={cd.expiredText}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/** Windows already scoped to this contract's routes/direction by the API. */
|
||||
windows: MyBookingWindow[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking windows on THIS contract's routes only (the API filters by the
|
||||
* contract's route lanes, which also pins the import/export direction) — the
|
||||
* contract-scoped counterpart of the home dashboard's all-lanes Booking Windows
|
||||
* card. Paged three cards at a time; hidden when nothing is announced.
|
||||
*/
|
||||
export function ContractBookingWindowsSection({
|
||||
windows,
|
||||
isLoading,
|
||||
}: ContractBookingWindowsSectionProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const rows = windows.filter(
|
||||
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
|
||||
);
|
||||
// Order by the train's dispatch (departure) date, nearest first — the
|
||||
// shipment leaving soonest leads. Open-now breaks ties on the same departure.
|
||||
return rows.sort((a, b) => {
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
});
|
||||
}, [windows]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE));
|
||||
const safePage = Math.min(page, pageCount - 1);
|
||||
const visible = sorted.slice(
|
||||
safePage * PER_PAGE,
|
||||
safePage * PER_PAGE + PER_PAGE,
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<CalendarClock size={18} color={MUTED} />
|
||||
<Box>
|
||||
<Text fw={700} fz={16} style={{ color: INK }}>
|
||||
Booking windows
|
||||
</Text>
|
||||
<Text fz={13} style={{ color: MUTED }}>
|
||||
Windows on this contract's routes (EAT)
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="xl"
|
||||
size="lg"
|
||||
aria-label="Previous windows"
|
||||
disabled={safePage <= 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</ActionIcon>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
{Array.from({ length: pageCount }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
onClick={() => setPage(i)}
|
||||
style={{
|
||||
width: i === safePage ? 18 : 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: i === safePage ? "#0A6F4D" : "#D8E2EB",
|
||||
transition: "width 200ms ease, background 200ms ease",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius="xl"
|
||||
size="lg"
|
||||
aria-label="Next windows"
|
||||
disabled={safePage >= pageCount - 1}
|
||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height={150} radius="md" />
|
||||
))}
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<WindowStatusBanner windows={sorted} />
|
||||
<SimpleGrid
|
||||
key={safePage}
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="md"
|
||||
>
|
||||
{visible.map((w) => (
|
||||
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import { getContractBookingAction } from "./contract-booking-action";
|
||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||
import { ContractBookingWindowsSection } from "./ContractBookingWindowsSection";
|
||||
import {
|
||||
BORDER,
|
||||
ContractStatusBadge,
|
||||
@@ -200,7 +201,7 @@ export default function ContractDetailPage() {
|
||||
// Booking windows for this contract's routes — gates the direct "New shipment
|
||||
// booking" entry so the customer only sees it while a window is open.
|
||||
// Refetched every minute so "Open now" flips without a manual reload.
|
||||
const { data: bookingWindows = [] } = useQuery({
|
||||
const { data: bookingWindows = [], isLoading: windowsLoading } = useQuery({
|
||||
...api.bookings.getContractBookingWindows.queryOptions({
|
||||
input: { contractId: id! },
|
||||
refetchInterval: 60_000,
|
||||
@@ -515,6 +516,16 @@ export default function ContractDetailPage() {
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
{/* Booking windows on this contract's routes/direction only (the API
|
||||
filters by the contract's lanes). Intercity contracts aren't
|
||||
window-gated, so nothing is shown for them. */}
|
||||
{contract.tradeDirection !== "DOMESTIC" && (
|
||||
<ContractBookingWindowsSection
|
||||
windows={bookingWindows}
|
||||
isLoading={windowsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs: Details · Documents · Bookings (pill style, like the
|
||||
backoffice booking-requests page; each tab shows a count badge). */}
|
||||
<Tabs
|
||||
|
||||
@@ -507,23 +507,17 @@ export default function NewContractPage({
|
||||
const isContainer = data.cargoType === "container";
|
||||
|
||||
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
||||
// size; bulk: a single commodity row.
|
||||
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
|
||||
const isGeneral = data.contractKind === "general_contract";
|
||||
// size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped
|
||||
// (quantityCap omitted → NULL): the customer books repeatedly against a
|
||||
// GENERAL contract until its validity expires.
|
||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||
? data.enabledContainerSizes.map((size) => ({
|
||||
containerSize: size,
|
||||
quantityCap:
|
||||
isGeneral && data.containerSizeCaps[size]
|
||||
? data.containerSizeCaps[size]
|
||||
: undefined,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||
cargoFreeText: data.cargoFreeText || undefined,
|
||||
quantityCap:
|
||||
isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -662,7 +656,9 @@ export default function NewContractPage({
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
{isEdit
|
||||
? "Update your contract details and documents, then resubmit it for EDR staff review."
|
||||
? editContract?.status === "CHANGES_REQUESTED"
|
||||
? "Update your contract details and documents, then resubmit it for EDR staff review."
|
||||
: "Update your draft contract details and documents, then submit it for EDR staff review."
|
||||
: "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -685,7 +681,7 @@ export default function NewContractPage({
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Box flex={1} p="24px">
|
||||
{isEdit && (
|
||||
{isEdit && editContract?.status === "CHANGES_REQUESTED" && (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="lg"
|
||||
|
||||
@@ -26,20 +26,22 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* The soonest upcoming (not-yet-open) window with a known opening time, so the
|
||||
* customer can be told when to come back. Returns `null` when nothing upcoming
|
||||
* carries an opening time.
|
||||
* The next upcoming (not-yet-open) window the customer should come back for —
|
||||
* the one whose train dispatches soonest, so it lines up with the departure-date
|
||||
* ordering of the cards. Returns `null` when nothing upcoming carries an opening
|
||||
* time. (`windowOpensAt` is still required so the banner can name a come-back time.)
|
||||
*/
|
||||
export function soonestUpcomingWindow(
|
||||
windows: MyBookingWindow[],
|
||||
): MyBookingWindow | null {
|
||||
const upcoming = windows
|
||||
.filter((w) => !w.isOpenNow && w.windowOpensAt)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.windowOpensAt!).getTime() -
|
||||
new Date(b.windowOpensAt!).getTime(),
|
||||
);
|
||||
.sort((a, b) => {
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
||||
});
|
||||
return upcoming[0] ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
||||
PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning },
|
||||
PAID: { label: "Paid", ...TONE.success },
|
||||
IN_TRANSIT: { label: "In Transit", ...TONE.info },
|
||||
ARRIVED: { label: "Arrived", ...TONE.success },
|
||||
COMPLETED: { label: "Completed", ...TONE.success },
|
||||
};
|
||||
|
||||
|
||||
@@ -9,23 +9,27 @@ import { fieldStyles } from "./shared";
|
||||
|
||||
export function PaymentCurrencyField({
|
||||
control,
|
||||
etbOnly = false,
|
||||
}: {
|
||||
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 (
|
||||
<Controller
|
||||
name="paymentCurrency"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => {
|
||||
const selected = PAYMENT_CURRENCY_OPTIONS.find(
|
||||
(o) => o.value === field.value,
|
||||
);
|
||||
const selected = options.find((o) => o.value === field.value);
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
label="Payment Currency *"
|
||||
placeholder="Select currency…"
|
||||
data={PAYMENT_CURRENCY_OPTIONS.map((o) => ({
|
||||
data={options.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
|
||||
@@ -129,7 +129,9 @@ export const contractFormSchema = z
|
||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||
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."),
|
||||
|
||||
firstMile: z
|
||||
@@ -220,6 +222,14 @@ export const contractFormSchema = z
|
||||
},
|
||||
)
|
||||
.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") {
|
||||
// Container scope: at least one enabled size.
|
||||
if (data.enabledContainerSizes.length === 0) {
|
||||
@@ -252,29 +262,10 @@ export const contractFormSchema = z
|
||||
});
|
||||
}
|
||||
}
|
||||
// GENERAL contracts must carry a real (> 0) quantity cap — an untouched
|
||||
// NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the
|
||||
// Cargo & Route step until the customer enters a quantity.
|
||||
if (data.contractKind === "general_contract") {
|
||||
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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
// GENERAL contracts are uncapped: no quantity cap is collected, so the
|
||||
// customer can book repeatedly until the contract's validity expires. The
|
||||
// cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at
|
||||
// the API. No cap validation is applied.
|
||||
});
|
||||
|
||||
export type ContractFormValues = z.infer<typeof contractFormSchema>;
|
||||
@@ -286,7 +277,8 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
previousContractRef: "",
|
||||
|
||||
serviceTypeId: "",
|
||||
paymentCurrency: "USD",
|
||||
// No preselected currency — the customer must choose (intercity forces ETB).
|
||||
paymentCurrency: undefined,
|
||||
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
equipmentReturn: "with_return",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user