Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-20 08:20:34 +00:00
178 changed files with 11076 additions and 3168 deletions

View File

@@ -277,6 +277,9 @@ export class AppModule implements OnApplicationBootstrap {
// await this.demoUsersSeeder.run();
// await this.freightStaffUsersSeeder.run();
// await this.pricingDataSeeder.run();
// IndodeFacilitySeeder keys its warehouses on INDODE_OPEN / INDODE_CLOSED, so
// it will not recognise a hand-created Indode warehouse and will seed a second
// one alongside it. Only enable it against an Indode that has no warehouse.
// await this.indodeFacilitySeeder.run();
// await this.batch14TestDataSeeder.run();
// await this.batch5TestDataSeeder.run();

View File

@@ -0,0 +1,72 @@
import { validate } from 'class-validator';
import { IsISO8601, IsOptional } from 'class-validator';
import { CLOCK_SKEW_TOLERANCE_MS, IsNotBackdated } from './is-not-backdated.validator';
class Subject {
@IsOptional()
@IsISO8601()
@IsNotBackdated()
occurredAt?: string;
}
const subjectWith = (occurredAt?: string) => {
const subject = new Subject();
subject.occurredAt = occurredAt;
return subject;
};
const errorsFor = async (occurredAt?: string) => validate(subjectWith(occurredAt));
const backdatedErrors = (errors: Awaited<ReturnType<typeof errorsFor>>) =>
errors.filter((error) => Object.keys(error.constraints ?? {}).includes('IsNotBackdated'));
describe('IsNotBackdated', () => {
it('rejects a timestamp from the past', async () => {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const errors = await errorsFor(yesterday);
expect(backdatedErrors(errors)).toHaveLength(1);
expect(errors[0].constraints?.IsNotBackdated).toBe(
'occurredAt cannot be backdated — it must be now or later',
);
});
it('accepts now', async () => {
const errors = await errorsFor(new Date().toISOString());
expect(errors).toHaveLength(0);
});
it('accepts a value stale only by transit and clock skew', async () => {
// What an honest caller sends: "now" as of when the request was built.
const almostNow = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS - 5_000)).toISOString();
const errors = await errorsFor(almostNow);
expect(errors).toHaveLength(0);
});
it('rejects a value staler than the skew allowance', async () => {
const tooStale = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS + 5_000)).toISOString();
const errors = await errorsFor(tooStale);
expect(backdatedErrors(errors)).toHaveLength(1);
});
it('ignores an absent value so @IsOptional decides', async () => {
const errors = await errorsFor(undefined);
expect(errors).toHaveLength(0);
});
it('leaves an unparseable value to the format validator', async () => {
const errors = await errorsFor('not-a-date');
// Reported as a format problem, not as a backdate.
expect(backdatedErrors(errors)).toHaveLength(0);
expect(errors[0].constraints).toHaveProperty('isIso8601');
});
});

View File

@@ -0,0 +1,56 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
/**
* A caller may not stamp an event as having happened before now.
*
* A request cannot reach the server at the instant it was built, and a caller's
* clock is not the server's, so a timestamp that honestly means "now" always
* arrives a little stale. Comparing straight against `Date.now()` would reject
* it. The skew allowance below is what makes an honest "now" pass — it is not a
* window for backdating, and it is deliberately far too small to reach any
* earlier event worth backdating to.
*/
export const CLOCK_SKEW_TOLERANCE_MS = 60_000;
@ValidatorConstraint({ name: 'IsNotBackdated', async: false })
export class IsNotBackdatedConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
// Absence is not this validator's business; pair with @IsOptional.
if (value === undefined || value === null || value === '') return true;
const parsed = new Date(value as string | Date);
// An unparseable value is a format error — let @IsISO8601/@IsDateString own
// that message rather than reporting it as a backdate.
if (Number.isNaN(parsed.getTime())) return true;
const toleranceMs = (args.constraints?.[0] as number | undefined) ?? CLOCK_SKEW_TOLERANCE_MS;
return parsed.getTime() >= Date.now() - toleranceMs;
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} cannot be backdated — it must be now or later`;
}
}
/**
* Rejects a timestamp earlier than now, give or take {@link CLOCK_SKEW_TOLERANCE_MS}.
* Pass a different tolerance only with a reason.
*/
export function IsNotBackdated(
toleranceMs: number = CLOCK_SKEW_TOLERANCE_MS,
validationOptions?: ValidationOptions,
) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [toleranceMs],
validator: IsNotBackdatedConstraint,
});
};
}

View File

@@ -0,0 +1,98 @@
import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder';
import { Rate } from '../modules/rule-engine/entities/rate.entity';
/** Minimal Rate factory for the builder unit tests. */
function rate(partial: Partial<Rate>): Rate {
return {
trigger: 'ALWAYS',
appliesTo: 'CONTAINER',
tradeDirection: 'IMPORT',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 200,
rateUnit: 'PER_CONTAINER',
...partial,
} as Rate;
}
describe('ContractRateScheduleBuilder', () => {
const LIVE: Rate[] = [
rate({
appliesTo: 'CONTAINER',
tradeDirection: 'IMPORT',
rateType: 'CONTAINER_IMPORT',
rateValue: 200,
rateUnit: 'PER_CONTAINER',
originYard: { label: 'Negad' } as never,
destinationYard: { label: 'Mojo Dry Port' } as never,
containerType: { label: '40ft GP' } as never,
}),
rate({
appliesTo: 'CONTAINER',
tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import
rateType: 'CONTAINER_EXPORT',
rateValue: 819,
originYard: { label: 'GMP' } as never,
destinationYard: { label: 'SGTD' } as never,
}),
rate({
appliesTo: 'BULK', // wrong freight — filtered out for a container contract
tradeDirection: 'IMPORT',
rateType: 'BULK_IMPORT',
rateUnit: 'PER_WAGON',
rateValue: 100,
}),
rate({
appliesTo: 'FIRST_MILE',
trigger: 'ALWAYS',
tradeDirection: null,
rateUnit: 'PER_CONTAINER',
rateValue: 50,
}),
rate({
appliesTo: 'OTHER',
trigger: 'CUSTOMS_CLEARANCE',
tradeDirection: null,
rateType: 'CUSTOMS_CLEARANCE',
rateUnit: 'FLAT',
rateValue: 120,
}),
];
const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => {
const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) };
return new ContractRateScheduleBuilder(service as never).build(dir, freight);
};
it('shows only import container lanes for an import container contract', async () => {
const s = await build('IMP', 'CON');
expect(s.freightLanes).toHaveLength(1);
expect(s.freightLanes[0]).toMatchObject({
route: 'Negad → Mojo Dry Port',
cargo: '40ft GP',
currency: 'USD',
amount: '200',
unit: 'per container',
});
});
it('always lists route-agnostic services and surcharges', async () => {
const s = await build('IMP', 'CON');
expect(s.additionalServices).toHaveLength(1);
expect(s.additionalServices[0].route).toBe('First-mile pickup by truck');
expect(s.surcharges).toHaveLength(1);
expect(s.surcharges[0].route).toBe('Customs clearance service');
});
it('excludes container lanes from a bulk contract', async () => {
const s = await build('IMP', 'BULK');
expect(s.freightLanes).toHaveLength(1);
expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' });
});
it('flags an empty schedule when nothing priced matches', async () => {
const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) };
const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON');
expect(s.isEmpty).toBe(true);
});
});

View File

@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Repair for environments missing the GPS tracking tables.
*
* AddGpsTracking2000000000000 creates freight.gps_devices / gps_positions, but
* some databases have it RECORDED in public.migrations without the tables ever
* landing. TypeORM never re-runs a recorded migration, so those environments
* stay broken through any number of restarts — the GT06 listener accepts tracker
* packets on its TCP port regardless of schema state and fails per packet with
* `relation "freight.gps_devices" does not exist`, dropping position fixes.
*
* This re-issues the same DDL under a new name so it is applied afresh. Every
* statement is IF NOT EXISTS, so it is a no-op where the tables already exist
* and safe on every environment.
*
* Kept byte-identical to the original DDL on purpose: this must converge on the
* schema the entities expect, not a variant of it.
*/
export class RepairGpsTrackingTables2300000000000 implements MigrationInterface {
name = "RepairGpsTrackingTables2300000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
imei varchar(20) NOT NULL UNIQUE,
name varchar,
vehicle_id uuid REFERENCES freight.vehicles(id),
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
last_seen_at timestamptz,
last_lat numeric(10,6),
last_lng numeric(10,6),
last_speed numeric(6,2),
last_course int,
last_fix_at timestamptz,
voltage_level int,
gsm_level int,
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_GPS_DEVICES_VEHICLE"
ON freight.gps_devices (vehicle_id)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
imei varchar(20) NOT NULL,
vehicle_id uuid,
lat numeric(10,6) NOT NULL,
lng numeric(10,6) NOT NULL,
speed numeric(6,2) NOT NULL DEFAULT 0,
course int NOT NULL DEFAULT 0,
satellites int NOT NULL DEFAULT 0,
positioned boolean NOT NULL DEFAULT false,
gps_time timestamptz NOT NULL,
alarm int NOT NULL DEFAULT 0,
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_GPS_POSITIONS_DEVICE_TIME"
ON freight.gps_positions (device_id, gps_time)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
ON freight.gps_positions (vehicle_id, gps_time)
`);
}
public async down(): Promise<void> {
// No-op: dropping the tables would discard tracker history on environments
// where this migration was the one that created them. AddGpsTracking owns
// the teardown.
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A facility handles what its equipment can handle. Containers need a reach
* stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs
* far less, so all five facilities load and unload it.
*
* Both default true — a facility handles everything unless someone says
* otherwise, which keeps existing rows working and makes the seeder the place
* where the real capability is stated.
*/
export class YardFacilityFreightTypes2320000000000 implements MigrationInterface {
name = 'YardFacilityFreightTypes2320000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
DROP COLUMN IF EXISTS handles_container,
DROP COLUMN IF EXISTS handles_bulk
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Refresh the `pricing` article body of the six seeded contract templates to
* the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per
* wagon") are now rendered from the LIVE rate config instead of frozen prose,
* so any template whose pricing article still carries a hardcoded price token
* is rewritten to the current seed text.
*
* The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original
* prose (which always quoted a currency + figure) and matches neither an
* already-migrated body nor a hand-edited one that adopted the schedule
* wording — so admin edits are preserved. Idempotent: after the rewrite the
* price token is gone, so a re-run is a no-op. Fresh databases seed the new
* text directly (CreateContractTemplates imports the same seed), making this
* a targeted backfill for databases seeded before the seed changed.
*/
const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]';
export class RefreshContractPricingArticles2360000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
const pricing = seed.articles.find((a) => a.id === 'pricing');
if (!pricing) continue;
// Rewrite only the article whose id = 'pricing', in place, and only when
// its body still quotes a hardcoded currency figure. jsonb_agg keeps the
// rest of the article (id/title/order) and every other article intact.
await queryRunner.query(
`
UPDATE freight.contract_templates AS t
SET articles = (
SELECT jsonb_agg(
CASE
WHEN elem->>'id' = 'pricing'
THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true)
ELSE elem
END
ORDER BY ord
)
FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord)
),
updated_at = now()
WHERE t.code = $1
AND EXISTS (
SELECT 1
FROM jsonb_array_elements(t.articles) AS x
WHERE x->>'id' = 'pricing'
AND x->>'body' ~ $3
);
`,
[seed.code, pricing.body, HARDCODED_PRICE_TOKEN],
);
}
}
/**
* Irreversible in practice — the original per-lane figures are not restored.
* A no-op down keeps the migration reversible-by-contract without
* resurrecting stale hardcoded prices.
*/
public async down(): Promise<void> {
// intentionally empty
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-container handling opt-in: each physical container can now be marked
* hazardous / reefer / with-return individually, next to its VGM. The hazardous
* and reefer flags already existed on the unit row; only the return leg was
* missing, so a booking of 20 containers with 10 returning empty can bill the
* WITH_RETURN surcharge on 10 instead of all 20.
*
* Backfill: existing rows keep false. The line-level counts
* (booking_container.return_quantity etc.) stay authoritative for bookings made
* before this change — the rule engine falls back to them when no unit is flagged.
*/
export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface {
name = 'AddContainerUnitReturnFlag2370000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`,
);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* New built-train lifecycle status DEACTIVATED: staff park a train indefinitely
* (only allowed while it has no DRAFT/SCHEDULED/DISPATCHED schedule). Like
* UNDER_MAINTENANCE / OUT_OF_SERVICE it is staff-owned — the scheduler never
* overwrites it and refuses to schedule a deactivated train.
*
* Postgres cannot drop an enum value, so down() is a no-op.
*/
export class AddTrainDeactivatedStatus2380000000000 implements MigrationInterface {
name = 'AddTrainDeactivatedStatus2380000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`,
);
}
public async down(): Promise<void> {
// Enum values cannot be removed in Postgres; leaving the label is harmless.
}
}

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Admin-managed catalog of IMPORT run numbers (even, Djibouti → Ethiopia)
* selectable in the Train Builder. The paired EXPORT number is derived
* (import 1), so only the import side is configured. Seeded with the runs
* historically hardcoded in the backoffice's trainRuns constants; admins add
* new runs from the Dropdown Settings editor.
*/
export class SeedImportTrainNumbers2390000000000 implements MigrationInterface {
name = 'SeedImportTrainNumbers2390000000000';
private readonly code = 'import_train_numbers';
private readonly options: string[] = [
'8002',
'8102',
'8202',
'8302',
'8402',
'8502',
'8602',
'8702',
'8802',
'8902',
'9002',
];
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
[this.code],
);
if (existing.length > 0) return;
const inserted = await queryRunner.query(
`INSERT INTO freight.dropdown_settings (code, label, description, multiple, meta)
VALUES ($1, $2, $3, false, $4::jsonb)
RETURNING id;`,
[
this.code,
'Import train numbers',
'Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import 1).',
JSON.stringify({ searchable: true, clearable: true }),
],
);
const settingId = inserted[0].id;
for (let i = 0; i < this.options.length; i++) {
const value = this.options[i];
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, $4);`,
[settingId, value, value, i],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
this.code,
]);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard soft-delete now appends `@<epoch-ms>` to the unique code (SEBETA →
* SEBETA@1755612345678) so the name can be reused by a new yard while
* UQ_yards_code still spans soft-deleted rows. varchar(20) can't hold long
* codes plus the 14-char suffix, so widen to 40.
*/
export class WidenYardCodeForSoftDeleteSuffix2390000000000 implements MigrationInterface {
name = 'WidenYardCodeForSoftDeleteSuffix2390000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`,
);
}
public async down(): Promise<void> {
// Narrowing would fail on suffixed codes; keep 40.
}
}

View File

@@ -305,6 +305,10 @@ export class BookingPricingService {
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
reeferQuantity: Number(bc.reeferQuantity ?? 0),
returnQuantity: Number(bc.returnQuantity ?? 0),
},
perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty,

View File

@@ -1,5 +1,4 @@
import { Inject, Injectable } from "@nestjs/common";
import { In, Not } from "typeorm";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
@@ -34,8 +33,6 @@ import {
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
@@ -134,10 +131,7 @@ export class BookingReferenceDataService {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
await Promise.all([
this.yardsRepository.findAll({
where: {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.containerTypesRepository.findAll({

View File

@@ -42,6 +42,7 @@ export class BookingTransitionService {
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
@Inject(forwardRef(() => BookingContractService))
private readonly contractService: BookingContractService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@@ -1049,7 +1050,25 @@ export class BookingTransitionService {
booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType);
if (isExportTrain) {
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
// With export split ON the booking no longer has to ride ONE train whole:
// the largest fitting part is offered and the leftover rebooks on the next
// train. So the day is only unbookable when NO export train that day has
// any room at all — reject on the day total, not on a single-train fit.
// With the flag off this stays the strict whole-booking gate.
if (process.env.FREIGHT_EXPORT_SPLIT === "true") {
const fitting = await this.bookingBatchService.fittingTrainsForDay(
scheduledBooking,
eatDay(date),
"EXPORT",
);
if (!fitting.length) {
throw new ConflictException(
"No export train on this day has space left — pick another shipment day.",
);
}
} else {
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
}
}
await this.bookingsRepository.update(bookingId, {

View File

@@ -1287,6 +1287,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Same as {@link findAllBySchedule} but for a page of schedules at once —
* one query instead of one per schedule (batch monitoring board). */
findAllBySchedules(scheduleIds: string[]): Promise<Booking[]> {
if (!scheduleIds.length) return Promise.resolve([]);
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds })
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
@@ -1342,6 +1359,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
if (!bookingIds.length) return Promise.resolve([]);
return this.bookingRepo(manager).find({
where: { id: In(bookingIds) },
// Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins
// multiply rows badly in a single join (hot path for every allocation
// preview / assignment validation).
relationLoadStrategy: 'query',
relations: {
company: true,
originYard: true,

View File

@@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity {
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;
/** This container ships back empty after unloading (equipment return). */
@Column({ name: 'is_return', type: 'boolean', default: false })
isReturn!: boolean;
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;

View File

@@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
@@ -85,6 +88,8 @@ export interface BookingClearanceView {
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Every risk decision, oldest first; the last entry is the current level. */
riskHistory?: RiskAssignmentRecord[];
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
@@ -282,6 +287,12 @@ export class BookingClearanceService {
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString()
: null,
// Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are
// the current one; this is the trail behind it.
riskHistory:
riskMilestone?.status === 'COMPLETED'
? (riskMilestone.metadata?.riskHistory ?? [])
: [],
secondDuty,
importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',

View File

@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('YELLOW');
});
/**
* The level is customer-visible and stays correctable until duty is advised,
* so a changed level must leave a trail rather than overwrite the last one.
*/
describe('risk history', () => {
it('records the first assignment with no previous level', async () => {
const { service } = makeService('COMPLETED');
const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.');
expect(saved.metadata?.riskHistory).toHaveLength(1);
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
level: 'RED',
assignedByUserId: 'user-1',
assignedBy: 'Abebe K.',
note: 'initial rating',
});
expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel');
});
it('keeps the earlier decision when the level is reassigned', async () => {
const { service } = makeService('COMPLETED');
await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.');
const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.');
expect(saved.metadata?.riskLevel).toBe('GREEN');
expect(saved.metadata?.riskHistory).toHaveLength(2);
// The original RED decision survives, with who made it.
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
level: 'RED',
assignedBy: 'Abebe K.',
});
expect(saved.metadata?.riskHistory?.[1]).toMatchObject({
level: 'GREEN',
previousLevel: 'RED',
assignedByUserId: 'user-2',
assignedBy: 'Sara M.',
note: 'downgraded',
});
});
it('keeps the whole chain across several reassignments, oldest first', async () => {
const { service } = makeService('COMPLETED');
await service.assignRisk('b-1', 'GREEN');
await service.assignRisk('b-1', 'YELLOW');
const saved = await service.assignRisk('b-1', 'RED');
expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([
'GREEN',
'YELLOW',
'RED',
]);
});
it('does not record a repeat of the level already assigned', async () => {
const { service } = makeService('COMPLETED');
await service.assignRisk('b-1', 'GREEN');
const saved = await service.assignRisk('b-1', 'GREEN');
expect(saved.metadata?.riskHistory).toHaveLength(1);
});
it('always leaves riskLevel equal to the last history entry', async () => {
const { service } = makeService('COMPLETED');
await service.assignRisk('b-1', 'RED');
const saved = await service.assignRisk('b-1', 'YELLOW');
const history = saved.metadata?.riskHistory ?? [];
expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level);
});
});
});

View File

@@ -210,15 +210,50 @@ export class ClearanceMilestoneService {
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
* catalog order T1_CLOSED → RISK_ASSIGNED.
*
* The level stays correctable until duty is advised off it, so each assignment
* is appended to `riskHistory` instead of silently replacing the last one — a
* customer-visible level that changes needs a trail of who changed it and when.
*/
async assignRisk(
bookingId: string,
riskLevel: CustomsRiskLevel,
userId?: string,
note?: string,
actor?: string,
): Promise<ClearanceMilestone> {
await this.assertT1Closed(bookingId);
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
const existing = await this.repo.findOne({
where: { bookingId, milestoneCode: 'RISK_ASSIGNED' },
});
const previousLevel = existing?.metadata?.riskLevel;
const history = existing?.metadata?.riskHistory ?? [];
// A repeat of the level already assigned is not a decision — recording it
// would pad the trail with entries that changed nothing.
const entries =
previousLevel === riskLevel
? history
: [
...history,
{
level: riskLevel,
...(previousLevel ? { previousLevel } : {}),
assignedAt: new Date().toISOString(),
assignedByUserId: userId ?? null,
assignedBy: actor ?? null,
note: note ?? null,
},
];
return this.completeWithMetadata(
bookingId,
'RISK_ASSIGNED',
{ riskLevel, riskHistory: entries },
userId,
note,
);
}
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */

View File

@@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
@@ -39,7 +40,10 @@ import { ContractsRepository } from './contracts.repository';
import { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
import {
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
@@ -54,6 +58,18 @@ export interface CreateBookingUnderContractResult {
warnings: string[];
}
/**
* Outstanding split remainder of a contract: what was booked in the first split
* booking's pre-split snapshot MINUS everything currently booked. Container
* contracts report per size; bulk reports one tonnage figure. `null` when the
* contract has no live split chain. Consumed by the remainder-placement engine
* to size the auto-created remainder booking.
*/
export type SplitOutstanding = {
bySize: Map<string, { total: number; outstanding: number }>;
bulk: { total: number; outstanding: number } | null;
};
/**
* The single create path for shipment bookings under a contract.
*
@@ -270,8 +286,8 @@ export class ContractBookingService {
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
@@ -580,11 +596,40 @@ export class ContractBookingService {
if (!booking || booking.contractId !== contract.id) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
}
if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) {
if (
!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes(
booking.status,
)
) {
throw new BadRequestException(
'Clearance must be finalized before the booking can be completed.',
);
}
// An unpaid booking that expired at train dispatch keeps its finished
// per-booking clearance — GL rebooks it onto a new shipment day instead of
// forcing the customer through a new shipment request + clearance fee.
if (booking.status === 'EXPIRED') {
// Only a booking that completed once (it has a price, so its clearance
// finished and cargo is persisted) can be rebooked after expiry.
if (!(Number(booking.totalAmount) > 0)) {
throw new BadRequestException(
'Only a previously completed booking can be rebooked after it expires.',
);
}
// Expiry released the booking's contract-capacity hold; if the payload
// re-states the cargo, make sure the released share is still free.
if (dto.containers?.length || dto.bulkLines?.length) {
await this.assertWithinQuantityCap(contract, dto);
}
// Drop the departed train's link and fall into the day-only resubmit
// path below — same machinery as OPERATION_CHANGES_REQUESTED.
await this.bookingsRepository.update(booking.id, {
status: 'OPERATION_CHANGES_REQUESTED',
trainScheduleId: null,
} as never);
booking.status = 'OPERATION_CHANGES_REQUESTED';
booking.trainScheduleId = null;
}
// Path B: only GL Ethiopia completes a customs instance — the customer
// never enters shipment data on a customs contract.
if (contract.customsClearingEnabled) {
@@ -966,9 +1011,11 @@ export class ContractBookingService {
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
* contract has no live split booking.
*/
private async splitOutstanding(
contract: Contract,
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
/**
* Public: the remainder-placement engine reads this to size the auto-created
* remainder booking. Returns `null` when there is no live split chain.
*/
async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
const first = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
@@ -1015,6 +1062,25 @@ export class ContractBookingService {
const probe = await this.buildExportProbe(contract, route, dto, yards);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (report.scheduleId) return;
// With export split ON a booking no longer has to ride ONE train whole: the
// largest fitting part is offered and the leftover is rebooked on the next
// train. Rejecting on the single-train fit here would block exactly the
// bookings the split exists to serve — including the auto-created remainder,
// which by definition did not fit the train it was split off. Fall back to
// the day total: unbookable only when NO export train that day has room.
if (process.env.FREIGHT_EXPORT_SPLIT === 'true') {
const fitting = await this.bookingBatchService.fittingTrainsForDay(
probe,
eatDay(new Date(dto.scheduledDate)),
'EXPORT',
);
if (fitting.length > 0) return;
throw new BadRequestException(
'No export train on this day has space left — pick another shipment day.',
);
}
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
@@ -1417,6 +1483,53 @@ export class ContractBookingService {
);
}
/**
* Per-line handling counts. Each physical container carries its own hazardous
* / reefer / return switch (entered next to its VGM), so the count is however
* many units opted in. Forms that predate per-unit switches send line-level
* counts and no unit flags — those are honoured as-is.
*/
private handlingCounts(line: CreateBookingContainerLineDto): {
hazardousQuantity: number;
reeferQuantity: number;
returnQuantity: number;
} {
const units = line.units ?? [];
const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn);
if (!flagged) {
return {
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
reeferQuantity: Number(line.reeferQuantity ?? 0),
returnQuantity: Number(line.returnQuantity ?? 0),
};
}
return {
hazardousQuantity: units.filter((u) => u.isHazardous).length,
reeferQuantity: units.filter((u) => u.isReefer).length,
returnQuantity: units.filter((u) => u.isReturn).length,
};
}
/**
* Booking-level hazardous / reefer flags. The CONTRACT gates the service; the
* per-container opt-ins decide whether THIS shipment actually uses it. A
* container contract that allows hazardous but a booking where nobody ticked
* the switch is not a hazardous booking, and must not fire the surcharge.
* Bulk keeps the contract flag — it has its own bulk*Quantity fields.
*/
private resolveShipmentHandlingFlag(
contract: Contract,
dto: CreateBookingUnderContractDto,
field: 'hazardousQuantity' | 'reeferQuantity',
): boolean {
const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer;
if (!gated) return false;
if (contract.freightType !== 'CONTAINER') return true;
const lines = dto.containers ?? [];
if (!lines.length) return Boolean(gated);
return lines.some((l) => this.handlingCounts(l)[field] > 0);
}
/**
* Resolve the booking's equipment return from the per-line return quantities
* (container freight). The CONTRACT gates the service — like hazardous:
@@ -1436,7 +1549,7 @@ export class ContractBookingService {
const lines = dto.containers ?? [];
for (const line of lines) {
const qty = Number(line.returnQuantity ?? 0);
const qty = this.handlingCounts(line).returnQuantity;
if (qty === 0) continue;
if (contract.equipmentReturn !== 'WITH_RETURN') {
throw new BadRequestException(
@@ -1452,7 +1565,7 @@ export class ContractBookingService {
}
if (contract.equipmentReturn === 'WITH_RETURN') {
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0);
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
}
return legacy;
@@ -1490,9 +1603,10 @@ export class ContractBookingService {
);
}
const counts = this.handlingCounts(line);
const containerType = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
contract.isReefer || counts.reeferQuantity > 0,
);
const vgmPerUnit = line.units.length
@@ -1506,12 +1620,10 @@ export class ContractBookingService {
containerTypeId: containerType.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
hazardousQuantity: counts.hazardousQuantity,
reeferQuantity: counts.reeferQuantity,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0)
: 0,
contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
@@ -1530,6 +1642,8 @@ export class ContractBookingService {
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
isReturn:
contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
sortOrder: sortOrder++,
}),
);
@@ -1630,12 +1744,14 @@ export class ContractBookingService {
paymentCurrency: contract.paymentCurrency,
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment,
shippingLineId: null,
contractRouteId: route?.id ?? null,
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
@@ -1644,11 +1760,11 @@ export class ContractBookingService {
containerTypeId: ct.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
reeferQuantity: this.handlingCounts(line).reeferQuantity,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0)
? this.handlingCounts(line).returnQuantity
: 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,

View File

@@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
@@ -102,6 +105,8 @@ export interface ContractClearanceView {
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Every risk decision, oldest first; the last entry is the current level. */
riskHistory?: RiskAssignmentRecord[];
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
@@ -365,6 +370,11 @@ export class ContractClearanceService {
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString()
: null,
// Every risk decision, oldest first — see booking-clearance.service.
riskHistory:
riskMilestone?.status === 'COMPLETED'
? (riskMilestone.metadata?.riskHistory ?? [])
: [],
secondDuty,
importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',

View File

@@ -31,6 +31,7 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
@@ -968,13 +969,16 @@ export class ContractsController {
assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.milestoneService.assignRisk(
bookingId,
dto.riskLevel,
resolveAuthUserId(user),
dto.note,
// Risk history is read by people, so resolve the name now — the id alone
// would render as a UUID in the trail.
actorLabel(user),
);
}

View File

@@ -50,6 +50,15 @@ export class CreateContainerUnitDto {
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
@ApiPropertyOptional({
default: false,
description: 'This container ships back empty (equipment return).',
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReturn?: boolean;
}
export class CreateBookingContainerLineDto {

View File

@@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
/**
* One customs risk decision. Risk stays correctable until duty is advised off
* it, and the level is customer-visible, so every assignment is kept rather than
* overwritten — a disputed level needs to show what was set, by whom, and when.
*/
export interface RiskAssignmentRecord {
level: CustomsRiskLevel;
/** The level this replaced; absent on the first assignment. */
previousLevel?: CustomsRiskLevel;
assignedAt: string;
assignedByUserId?: string | null;
/** Display name resolved at assignment time, so the trail never shows a UUID. */
assignedBy?: string | null;
note?: string | null;
}
/**
* Structured payload some milestones carry beyond a plain note (doc §11.3):
* - RISK_ASSIGNED → `riskLevel`
* - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment)
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
* Stored on the milestone so the timeline can render the value inline.
*/
export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel;
/**
* Append-only, oldest first. `riskLevel` is the current value and always
* equals the last entry's `level`.
*/
riskHistory?: RiskAssignmentRecord[];
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;

View File

@@ -256,6 +256,11 @@ export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
// Terminal contracts stay on the list — the clearance hub is GL's history of
// everything that passed through, not just the live work queue.
'EXPIRED',
'CANCELLED',
'REJECTED',
] as const;
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
@@ -275,12 +280,22 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED',
'ROAD_DISPATCH_PENDING',
// Payment phase — the booking is selected/awaiting the customer's payment.
'SELECTED_FOR_BATCH',
'PNR_GENERATED',
'AWAITING_PAYMENT',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'IN_TRANSIT',
'ARRIVED',
'PAID',
'COMPLETED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
// Terminal bookings stay on the list — EXPIRED especially: GL rebooks it
// from here, and the hub doubles as clearance history.
'EXPIRED',
'CANCELLED',
'REJECTED',
] as const;
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */

View File

@@ -74,9 +74,14 @@ export class CreateRateDto {
@Transform(({ value }) => Number(value))
rateValue!: number;
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
@ApiPropertyOptional({
enum: RATE_UNITS,
description:
'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.',
})
@IsOptional()
@IsIn([...RATE_UNITS])
rateUnit!: string;
rateUnit?: string;
}
export class SubmitRateForApprovalDto {

View File

@@ -0,0 +1,28 @@
import { deriveRateType } from './rate-type.util';
describe('deriveRateType — surcharge triggers', () => {
// Every surcharge trigger must land on its own rateType. A trigger with no
// mapping falls through to the base-freight branch and is silently stored as
// CANCELLATION_FEE, which both mislabels the booking's rate snapshot and
// hides the rate from contract pricing (which looks rateTypes up by name).
it.each([
['HAZARDOUS', 'HAZARD_SURCHARGE'],
['REEFER', 'REEFER_SURCHARGE'],
['WITH_RETURN', 'RETURN_SURCHARGE'],
['OVERWEIGHT', 'OVERWEIGHT_PER_TON'],
['SHIPPING_LINE', 'DOUBLE_HANDLING'],
['CONSOLIDATION', 'LASHING'],
['CANCELLATION', 'CANCELLATION_FEE'],
['DEMURRAGE', 'DEMURRAGE'],
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
] as const)('maps trigger %s to %s', (trigger, expected) => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
});
it('does not fall back to CANCELLATION_FEE for the empty-return service', () => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger: 'WITH_RETURN' })).not.toBe(
'CANCELLATION_FEE',
);
});
});

View File

@@ -25,6 +25,12 @@ export function deriveRateType(input: {
return 'HAZARD_SURCHARGE';
case 'REEFER':
return 'REEFER_SURCHARGE';
// Empty-container return service. Contract pricing looks this rateType up
// by name, so without the mapping a WITH_RETURN rate fell through to the
// base-freight branch and was stored as CANCELLATION_FEE — invisible to
// the contract, and mislabelled on the booking's snapshot.
case 'WITH_RETURN':
return 'RETURN_SURCHARGE';
case 'OVERWEIGHT':
return 'OVERWEIGHT_PER_TON';
case 'SHIPPING_LINE':

View File

@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
hasWarehouse!: boolean;
/**
* Containers need a reach stacker or gantry, so only the equipped facilities
* (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled
* everywhere.
*/
@Column({ name: 'handles_container', type: 'boolean', default: true })
handlesContainer!: boolean;
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
handlesBulk!: boolean;
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
equipmentNotes?: string | null;

View File

@@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm';
@Index(['country'])
@Index(['isActive'])
export class Yard extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
// 40 leaves room for the `@<epoch-ms>` suffix soft-delete appends to free the code.
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
code!: string;
@Column({ name: 'label', type: 'varchar', length: 100 })

View File

@@ -42,6 +42,14 @@ export interface BookingContainerEvalInput {
isReefer?: boolean;
isOverweight?: boolean;
overweightExcessTons?: number | null;
/**
* How many individual containers on this line opted into each handling
* service. PER_CONTAINER surcharges bill these counts, not the line
* quantity — 20 containers with 10 hazardous bill hazard on 10.
*/
hazardousQuantity?: number;
reeferQuantity?: number;
returnQuantity?: number;
}
export interface BookingEvaluationInput {
@@ -270,6 +278,27 @@ export class RuleEngineService {
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
/**
* Containers that opted into this trigger's handling service, summed
* across lines. null when the trigger isn't per-container handling (or
* no line carries a count) so the caller falls back to the full count.
*/
const optedInCount = (trigger: string | null): number | null => {
const field =
trigger === 'HAZARDOUS'
? 'hazardousQuantity'
: trigger === 'REEFER'
? 'reeferQuantity'
: trigger === 'WITH_RETURN'
? 'returnQuantity'
: null;
if (!field) return null;
const total = input.containers.reduce(
(sum, c) => sum + Number(c[field] ?? 0),
0,
);
return total > 0 ? total : null;
};
let triggerValue: number | null = null;
let calculatedAmount: number;
@@ -285,7 +314,11 @@ export class RuleEngineService {
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_CONTAINER':
triggerValue = containerCount;
// Handling surcharges bill only the containers that opted in, not the
// whole line — 20 containers with 10 hazardous bill hazard on 10.
// Legacy bookings carry no per-container counts (all 0) while their
// booking-level flag is set, so fall back to the full count there.
triggerValue = optedInCount(rate.trigger) ?? containerCount;
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_WAGON':

View File

@@ -68,15 +68,21 @@ export class RatesService {
private resolveRateUnit(
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'],
requestedUnit: Rate['rateUnit'] | undefined,
): Rate['rateUnit'] {
// Overweight is per-ton, full stop.
// Overweight is per-ton, full stop — the admin form hides the unit field
// for it and omits rateUnit from the payload entirely.
if (trigger === 'OVERWEIGHT') return 'PER_TON';
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
const allowed = allowedRateUnits({ appliesTo, trigger });
if (!requestedUnit) {
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
);
}
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
);
}
return requestedUnit;
@@ -272,7 +278,11 @@ export class RatesService {
tradeDirection,
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
});
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
const rateUnit = this.resolveRateUnit(
appliesTo,
trigger,
dto.rateUnit as Rate['rateUnit'] | undefined,
);
await this.assertNoDuplicatePattern({
rateType,

View File

@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
hasFacility: boolean;
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
hasWarehouse: boolean;
/** Containers need a reach stacker/gantry — not every facility has one. */
handlesContainer: boolean;
handlesBulk: boolean;
}
/**
* Which yards can handle cargo, and how.
* Which yards can handle cargo, and what kind.
*
* A yard is a load/unload point when `yards.has_facility` is set; the matching
* `yard_facilities` record says whether it also stores cargo. Facilities without a
* warehouse move cargo on and off the train and nothing more — no storage, no
* demurrage. This is the single resolver the journey and handling flows use, so
* they can't drift on what a facility is.
* `yard_facilities` record says what it can actually do — whether it stores cargo
* (storage/demurrage), and which freight types its equipment can lift. Containers
* need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
* bulk is handled at all five.
*
* This is the single resolver the journey and handling flows use, so they can't
* drift on what a facility is or what it can lift.
*/
@Injectable()
export class YardFacilitiesService {
constructor(private readonly dataSource: DataSource) {}
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
const [row]: Array<{
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
}> = await this.dataSource.query(
`SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
WHERE y.id = $1 AND y.deleted_at IS NULL`,
[yardId],
);
if (!row) return null;
private readonly SELECT = `
SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse",
f.handles_container AS "handlesContainer",
f.handles_bulk AS "handlesBulk"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
private toInfo(row: {
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
handlesContainer: boolean | null;
handlesBulk: boolean | null;
}): YardFacilityInfo {
// No facility record means no capability, whatever the flag says.
const hasFacility = Boolean(row.hasFacility);
return {
yardId: row.yardId,
yardCode: row.yardCode,
yardLabel: row.yardLabel,
hasFacility: Boolean(row.hasFacility),
// No facility record means no warehouse, whatever the flag says.
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
hasFacility,
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
handlesContainer: hasFacility && Boolean(row.handlesContainer),
handlesBulk: hasFacility && Boolean(row.handlesBulk),
};
}
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
const [row] = await this.dataSource.query(
`${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`,
[yardId],
);
return row ? this.toInfo(row) : null;
}
/** Every yard that can load/unload, for pickers and the intercity queues. */
async listFacilityYards(): Promise<YardFacilityInfo[]> {
const rows: Array<{
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
}> = await this.dataSource.query(
`SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
WHERE y.deleted_at IS NULL
AND y.is_active = true
AND y.has_facility = true
const rows = await this.dataSource.query(
`${this.SELECT}
WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
ORDER BY y.display_order ASC, y.label ASC`,
);
return rows.map((r) => ({
yardId: r.yardId,
yardCode: r.yardCode,
yardLabel: r.yardLabel,
hasFacility: true,
hasWarehouse: Boolean(r.hasWarehouse),
}));
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
}
/**
* Can this facility lift this cargo? Keeps the freight-type rule in one place
* so callers can't get it subtly wrong.
*/
canHandleFreight(
facility: YardFacilityInfo | null,
freightType: string | null | undefined,
): boolean {
if (!facility?.hasFacility) return false;
return String(freightType).toUpperCase() === 'CONTAINER'
? facility.handlesContainer
: facility.handlesBulk;
}
}

View File

@@ -31,7 +31,7 @@ export class YardsService {
/** Create a yard. */
async create(dto: CreateYardDto): Promise<Yard> {
const code = generateCode(dto.label);
const code = generateCode(dto.label).slice(0, 40);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
@@ -58,9 +58,19 @@ export class YardsService {
return updated;
}
/** Soft-delete a yard. */
/**
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
* same name can be created later without tripping UQ_yards_code, which spans
* soft-deleted rows too.
*/
async remove(id: string): Promise<void> {
await this.findById(id);
const yard = await this.findById(id);
const suffix = `@${Date.now()}`;
await this.repository.update(id, {
code: `${yard.code.slice(0, 40 - suffix.length)}${suffix}`,
label: `${yard.label.slice(0, 100 - suffix.length)}${suffix}`,
});
await this.repository.softDelete(id);
}

View File

@@ -21,6 +21,10 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
return this.repo(manager).findOne({
where: { id },
// One SELECT per relation instead of a single monster join — the nested
// wagon×allocation×booking×container branches multiply rows catastrophically
// when joined (measured ~925ms vs ~84ms on a 21-wagon schedule).
relationLoadStrategy: 'query',
relations: {
// Yards carry the route's display name; without them formatRouteLabel
// degrades to the literal "Origin → Destination". Milestones (with

View File

@@ -958,20 +958,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
// assertion below that says "not full" proves those axes are ignored.
const scheduleId = 'schedule-built';
const reservedBooking = (id: string) =>
const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
({
id,
freightType: 'BULK',
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
bookingContainers: [],
originYardId: 'yard-a',
destinationYardId: 'yard-b',
originYardId: leg?.origin ?? 'yard-a',
destinationYardId: leg?.dest ?? 'yard-b',
}) as unknown as Booking;
const buildService = (opts: {
physicalWagons: number;
reserved: Booking[];
maxWagons?: number;
routeStops?: string[];
}) => {
const schedule = {
id: scheduleId,
@@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
bookingWindowStatus: 'OPEN',
originStationId: 'yard-a',
destinationStationId: 'yard-b',
routeId: null,
routeId: opts.routeStops ? 'route-1' : null,
scheduleBookings: [],
trainSet: {
locomotive: {
@@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
},
};
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
const milestoneRepo = {
find: jest
.fn()
.mockResolvedValue(
(opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })),
),
};
const genericRepo = {
find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) =>
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
),
getRepository: jest.fn((entity: { name?: string }) => {
if (entity?.name === 'Wagon') return wagonRepo;
if (entity?.name === 'RouteMilestone') return milestoneRepo;
return genericRepo;
}),
transaction: jest.fn(),
};
const service = new BookingBatchService(
@@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
// left the pass-through edges reading "free" in the per-edge budget, so the
// full train's window cycled OPEN forever and the day pool never expired.
// A wagon is committed for the whole trip — leg-free edges are not capacity.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
reserved: [
reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }),
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
const { service } = buildService({
physicalWagons: 1,

View File

@@ -33,7 +33,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import { eatDay } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
@@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { RemainderPlacementService } from './remainder-placement.service';
import { BookingWindowGateway } from './booking-window.gateway';
import {
MAX_TEU_SLOTS_PER_WAGON,
@@ -171,23 +172,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
consolidationPartnerRef: string | null;
}
export interface BatchWindowGroup {
key: string;
label: string;
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
date: string;
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
dateLabel: string;
start: string;
end: string;
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
export interface BatchBoardCounts {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
}
/** A booking bucket on the detail board (in-window vs pending-contract). */
export interface BatchBoardBucket {
counts: BatchBoardCounts;
bookings: BatchBoardBookingDetail[];
}
@@ -214,8 +210,9 @@ export interface BatchBoardScheduleDetail {
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
/** Bookings inside the schedule's booking window (fully-executed contracts). */
bookings: BatchBoardBookingDetail[];
pendingContract: BatchBoardBucket;
allocationViolations: string[];
}
@@ -317,9 +314,29 @@ export class BookingBatchService implements OnModuleInit {
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
@Optional()
@Inject(forwardRef(() => RemainderPlacementService))
private readonly remainderPlacement?: RemainderPlacementService,
) {}
/**
* Auto-place a paid booking's split remainder onto the next fitting train.
* Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true.
*/
private get autoRemainderEnabled(): boolean {
return process.env.FREIGHT_AUTO_REMAINDER === "true";
}
/**
* Let EXPORT bookings split (offer the largest fitting part, leftover rebooks
* on the next train). Separate flag from auto-remainder: export touches the
* FCFS money path, so partial-offer can be enabled independently.
*/
private get exportSplitEnabled(): boolean {
return process.env.FREIGHT_EXPORT_SPLIT === "true";
}
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> {
const groups = await this.openRouteDayGroups();
@@ -496,6 +513,34 @@ export class BookingBatchService implements OnModuleInit {
// to the offered part before it boards (remainder returns to the contract cap).
if (this.splitService) {
await this.splitService.applySplit(bookingId);
// The split only happens on payment (here) — so auto-placing the remainder
// also only happens once the customer has accepted+paid. Re-read to see if
// applySplit actually reduced this booking (an open offer existed); if so,
// auto-create + place the remainder booking on the next fitting train.
// applySplit committed its own transaction before returning, so this reads
// the reduced lines. Best-effort: a placement failure never blocks the
// paid booking from boarding — the remainder falls back to manual rebook.
if (this.autoRemainderEnabled && this.remainderPlacement) {
const split = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
// Export remainders only auto-place when export split is on — otherwise
// an export booking never splits in the first place.
const directionOn =
split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled;
if (split?.isSplit && directionOn) {
await this.remainderPlacement
.placeRemainder(split)
.catch((err) =>
this.logger.error(
`Auto-place remainder failed for ${split.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
}
}
const linked =
@@ -731,6 +776,76 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* Trains that can carry a booking's leg on a given day, earliest departure
* first, each with the largest number of wagons it could still admit for the
* booking's wagon type. Direction-filtered: EXPORT bookings see export trains,
* IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL
* allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a
* non-primary allowed type still counts. The remainder placer uses this to
* pick the next fitting train; the `free` wagon count is the best across the
* allowed types (a train fits under whichever allowed type gives most room).
*/
async fittingTrainsForDay(
booking: Booking,
day: string,
direction: "IMPORT" | "EXPORT",
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.bookingWindowStatus !== "FULL" &&
(direction === "EXPORT"
? s.direction === "EXPORT"
: s.direction !== "EXPORT"),
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
const wagonDims = await this.loadWagonDims();
const dimsOptions = this.dimsForAllowed(booking, wagonDims);
const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = [];
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
const room = budget.remainingFor(leg);
// Best usable wagons across the allowed types — a train fits under
// whichever configured wagon type gives it the most room.
let freeWagons = 0;
for (const dims of dimsOptions) {
const w = this.bookableWithin(room, dims).wagons;
if (w > freeWagons) freeWagons = w;
}
if (freeWagons > 0) {
out.push({
scheduleId: schedule.id,
departure: schedule.scheduledDepartureDate!,
freeWagons,
});
}
}
return out;
}
/**
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
* summed across every train on the booking's corridor that day. Unlike the
@@ -789,6 +904,58 @@ export class BookingBatchService implements OnModuleInit {
return { freeWagons, need, trainsForDay };
}
/**
* Export split: no single train carries the whole booking, so offer the
* largest fitting part on the export train with the most room for its leg.
* Returns true when an offer was opened (the caller must NOT then reserve —
* the offer already opened its own pay window), false when the booking fits
* whole somewhere (normal FCFS path) or no meaningful partial exists.
*
* Only the offer is written here: the booking is reduced to the offered part
* on payment (applySplit), and the leftover is auto-placed afterwards. So an
* unpaid export booking stays whole and the customer may still cancel it.
*/
private async tryExportPartialOffer(booking: Booking): Promise<boolean> {
if (!this.splitService) return false;
const report = await this.exportSpaceReport(booking);
// A train fits it whole — nothing to split, take the normal path.
if (report.scheduleId) return false;
if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false;
if (!booking.scheduledDate) return false;
const day = eatDay(new Date(booking.scheduledDate));
const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT");
if (!fitting.length) return false;
// Most room first — the largest single part ships now, the smallest leftover
// is what has to find another train.
const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0];
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
target.scheduleId,
);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return false;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) return false;
const offered = await this.tryPartialOffer(
booking,
schedule.id,
budget.remainingFor(leg),
report.need,
);
if (!offered) return false;
this.logger.log(
`[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` +
`${schedule.id} — leftover rebooks on the next train once paid.`,
);
this.notifyBoardChanged(schedule.id, "batch_fill");
return true;
}
/**
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
* A consolidated booking reserves as a pair only once BOTH partners are ready
@@ -800,6 +967,15 @@ export class BookingBatchService implements OnModuleInit {
async acceptExportBooking(booking: Booking): Promise<void> {
const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) {
// Export split: when no single train carries the whole booking, offer the
// largest fitting part instead of failing the accept. The customer pays
// that part; on payment applySplit reduces this booking to it and the
// leftover is auto-placed as its own booking on the next train. Pairs are
// excluded (handled below) — a shared wagon is never split.
if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) {
const offered = await this.tryExportPartialOffer(booking);
if (offered) return;
}
const scheduleId = await this.pickExportSchedule(booking);
await this.reserveOnExport([booking], scheduleId);
return;
@@ -1013,11 +1189,32 @@ export class BookingBatchService implements OnModuleInit {
const wagonDims = await this.loadWagonDims();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
// One links query + one bookings query for the whole page (was 2 per card).
const scheduleIds = schedules.map((s) => s.id);
const [allLinks, allBookings] = await Promise.all([
scheduleIds.length
? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } })
: Promise.resolve([]),
this.bookingsRepository.findAllBySchedules(scheduleIds),
]);
const linkedIdsBySchedule = new Map<string, Set<string>>();
for (const l of allLinks) {
let set = linkedIdsBySchedule.get(l.trainScheduleId);
if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set()));
set.add(l.bookingId);
}
const bookingsBySchedule = new Map<string, Booking[]>();
for (const b of allBookings) {
if (!b.trainScheduleId) continue;
let list = bookingsBySchedule.get(b.trainScheduleId);
if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = []));
list.push(b);
}
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set<string>();
const bookings = bookingsBySchedule.get(s.id) ?? [];
const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims);
@@ -1046,7 +1243,8 @@ export class BookingBatchService implements OnModuleInit {
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
/** Schedule-level batch board: the schedule's own booking window plus its
* bookings split into in-window (contract executed) vs pending-contract. */
async getBatchBoardDetail(
scheduleId: string,
): Promise<BatchBoardScheduleDetail> {
@@ -1064,17 +1262,24 @@ export class BookingBatchService implements OnModuleInit {
}
const wagonDims = await this.loadWagonDims();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
// The full graph already carries the schedule↔booking links — no separate
// link query needed.
const linkedIds = new Set(
(s.scheduleBookings ?? []).map((l) => l.bookingId),
);
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited<
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
>;
try {
// Reuse the graph loaded above — the preview otherwise re-loads the same
// heavy schedule graph a second time per request.
allocationPreview =
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
await this.trainSchedulingService.previewAllocationForSchedule(
s.id,
s,
);
} catch {
allocationPreview = {
assignedBookingIds: [],
@@ -1144,73 +1349,22 @@ export class BookingBatchService implements OnModuleInit {
const loco = s.trainSet?.locomotive ?? null;
// Display windows are the REAL booking-window cycles this schedule was FROZEN
// with at creation (import: opens at its stored window time, lasts its rule's
// duration, reopens per its rule's delay; export: single FCFS lead window) —
// NOT the live global config. A later global-rules edit only re-derives
// not-yet-open schedules (restampPendingWindows), so an already-open schedule
// must keep drawing from its own snapshot, anchored on its stored open time.
// Legacy rows with no snapshot fall back to the live config.
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const num = (v: unknown, fallback: number) => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
const windowCfg = {
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour),
windowDurationHours: num(
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,
),
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
reopenGapMinutes: num(
s.ruleReopenDelayMinutes,
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
),
exportBookingLeadHours: num(
s.ruleExportBookingLeadHours,
liveCfg.exportBookingLeadHours,
),
// Frozen close offsets: a snapshot null means "no offset for this train"
// and stays null (not the live offset); only legacy rows lacking the
// column (undefined) fall back to live config.
importCloseOffsetMinutes:
s.ruleImportCloseOffsetMinutes !== undefined
? s.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
s.ruleExportCloseOffsetMinutes !== undefined
? s.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
s.direction ?? null,
departureDate,
windowCfg,
undefined,
s.windowOpensAt ?? null,
);
const emptyCounts = () => ({
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
});
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
const counts = emptyCounts();
for (const b of bookingsInWindow) {
// The board renders ONE booking window the schedule's own frozen window
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
// split into two buckets: contract executed (in the window) vs pending
// contract. The old per-cycle window projection was dropped — the UI never
// showed it, and reconstructing every cycle cost a config load + grouping
// pass per request.
const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => {
const counts: BatchBoardCounts = {
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
};
for (const b of bucket) {
if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === "READY") counts.ready += 1;
@@ -1221,26 +1375,8 @@ export class BookingBatchService implements OnModuleInit {
return counts;
};
const windows: BatchWindowGroup[] = [];
for (const [key, bucket] of windowBuckets) {
if (key === "pending-contract" || !bucket.window) continue;
const w = bucket.window;
windows.push({
key: w.key,
label: w.label,
date: w.date,
dateLabel: w.dateLabel,
start: w.start.toISOString(),
end: w.end.toISOString(),
counts: countFor(bucket.items),
bookings: bucket.items,
});
}
windows.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
);
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
const windowBookings = items.filter((i) => i.fullyExecutedAt);
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
return {
scheduleId: s.id,
@@ -1290,14 +1426,8 @@ export class BookingBatchService implements OnModuleInit {
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
windows,
bookings: windowBookings,
pendingContract: {
key: "pending-contract",
label: "Pending contract",
date: "",
dateLabel: "",
start: "",
end: "",
counts: countFor(pendingBookings),
bookings: pendingBookings,
},
@@ -1852,15 +1982,23 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* 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).
* A lone commercial 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).
*
* IMPORT is always eligible. EXPORT is eligible only when export split is
* enabled: export historically rides one train whole, so splitting it changes
* the FCFS money path — each split part still rides ONE train whole, and the
* leftover becomes its own booking on the next train.
*/
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
const directionOk =
booking.tradeDirection === "IMPORT" ||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
return (
!isPair &&
!booking.isGovernment &&
booking.tradeDirection === "IMPORT" &&
directionOk &&
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
this.splitService != null
);
@@ -2323,7 +2461,13 @@ export class BookingBatchService implements OnModuleInit {
if (!schedule || !locomotive) return null;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
// Built trains: collapse to a single train-wide pool so the freed capacity of
// a booking that alights mid-corridor is NOT re-offered on the pass-through
// leg (see remainingBudget). Keeps intercity accept consistent with the
// train-wide isTrainFull / committedWagons finalize signal.
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
collapseForBuiltTrain: true,
});
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
}
@@ -3117,7 +3261,14 @@ export class BookingBatchService implements OnModuleInit {
* (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
* no wagon type configured yet.
*/
/** Wagon types are near-static reference data — a short TTL cache spares one
* table scan per board/detail request without letting edits go stale long. */
private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null;
private async loadWagonDims(): Promise<WagonDims> {
if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) {
return this.wagonDimsCache.value;
}
const types = await this.dataSource.getRepository(WagonType).find();
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
@@ -3131,7 +3282,7 @@ export class BookingBatchService implements OnModuleInit {
// must fall back rather than yield an infinite wagon count.
const payload = (value: number | undefined, fallback: number): number =>
value && value > 0 ? value : fallback;
return {
const value: WagonDims = {
container: {
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
@@ -3144,6 +3295,8 @@ export class BookingBatchService implements OnModuleInit {
},
byWagonTypeId,
};
this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
@@ -3174,6 +3327,40 @@ export class BookingBatchService implements OnModuleInit {
};
}
/**
* EVERY wagon-type dimension a booking may ride — its cargo/container type's
* full allowed (many-to-many) wagon-type list, not just the first like
* {@link dimsFor}. The remainder placer needs the whole set so a train that
* stocks a non-primary allowed type still counts as fitting: a container type
* mapped to both NW5 and (say) NW7 must be measured against whichever a given
* train actually has free. Deduped by wagon-type id; falls back to the single
* representative dims when no allowed type is configured.
*/
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
const fallback =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const ids =
booking.freightType === "BULK"
? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id)
: (booking.bookingContainers ?? [])
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wt) => wt.id);
const seen = new Set<string>();
const dims: PerWagonDims[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
seen.add(id);
const d = wagonDims.byWagonTypeId.get(id);
if (d) {
dims.push({
...d,
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
});
}
}
return dims.length ? dims : [fallback];
}
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.
@@ -3211,6 +3398,7 @@ export class BookingBatchService implements OnModuleInit {
schedule: TrainSchedule,
limits: TrainLimits,
wagonDims: WagonDims,
opts?: { collapseForBuiltTrain?: boolean },
): Promise<CorridorBudget> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
@@ -3223,7 +3411,21 @@ export class BookingBatchService implements OnModuleInit {
tolerance: { weightTons: 0, lengthMeters: 0 },
};
}
const stops = await this.stopsForSchedule(schedule);
// A built train's wagons are coupled for the WHOLE trip, and the allocator
// commits each booking to a wagon for the entire route — it never reloads a
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
// its capacity is one train-wide pool, exactly as isTrainFull /
// committedWagons already count it. When a caller opts in, collapse the
// corridor to a single whole-route edge so every booking (full-route OR
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
// leg it merely passes through, instead of over-promising the freed slots.
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
// abstract slot/weight/length budget genuinely frees past an alight yard.
const stops =
physicalWagons != null && opts?.collapseForBuiltTrain
? [schedule.originStationId, schedule.destinationStationId]
: await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
@@ -3385,11 +3587,19 @@ export class BookingBatchService implements OnModuleInit {
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
// Built train: the physical consist is the only capacity axis, and a wagon
// is committed to its booking for the WHOLE trip — wagon allocation has no
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
// the Doraleh→Negad edge it merely passes through. Count commitments
// train-wide, not per corridor edge: the per-edge budget read "free slots"
// on pass-through legs of a sold-out consist, so the window of a full train
// cycled OPEN forever instead of concluding DONE (and the day pool's
// leftover bookings were never expired).
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
return (await this.committedWagons(schedule)) >= physicalWagons;
}
if ((await this.remainingWagons(schedule)) <= 0) return true;
// Built train: the physical consist is the only capacity axis. Weight and
// length were enforced when the consist was assembled (builder /
// adjust-consist), so a free wagon slot means the train genuinely has room.
if ((await this.builtTrainWagonCount(schedule)) != null) return false;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
const wagonDims = await this.loadWagonDims();
@@ -3398,6 +3608,29 @@ export class BookingBatchService implements OnModuleInit {
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
}
/**
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
* regardless of which corridor leg each rides. Deduped by booking id — a
* booking mid-settle can momentarily be both linked and reserved.
*/
private async committedWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims();
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const byId = new Map(
[...allocated, ...reserved].map((b) => [b.id, b] as const),
);
let total = 0;
for (const booking of byId.values()) {
total += this.wagonsFor(booking, wagonDims);
}
return total;
}
/**
* Smallest gross weight / shortest length one more wagon could add: the
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,

View File

@@ -352,10 +352,19 @@ export class BookingJourneyService {
): Promise<void> {
if (booking.tradeDirection !== 'DOMESTIC') return;
const facility = await this.yardFacilities.facilityForYard(yardId);
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
if (!facility?.hasFacility) {
throw new BadRequestException(
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
);
}
// A facility only handles what its equipment can lift: containers need a
// reach stacker/gantry, bulk does not.
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
throw new BadRequestException(
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
`an intercity booking cannot be ${where} here.`,
);
}
}

View File

@@ -150,10 +150,18 @@ export class BookingNotifierService {
): Promise<void> {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const leftover = totalWagons - offeredWagons;
// With auto-placement on, the leftover is booked FOR the customer on another
// train (its own invoice) — telling them to rebook it themselves would be
// wrong. Without it, the leftover returns to the contract to rebook.
const leftoverCopy =
process.env.FREIGHT_AUTO_REMAINDER === 'true'
? `The remaining ${leftover} will be booked for you on another train, with its own invoice. `
: `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `;
const msg =
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
leftoverCopy +
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
// HIGH: a split is a change to what the customer ordered AND a live payment
@@ -164,6 +172,23 @@ export class BookingNotifierService {
});
}
/**
* The wagons that did not fit the train the customer just paid for have been
* auto-booked as their own booking (`remainder`) — they ride another train and
* are billed separately. Sent instead of leaving the customer to rebook.
*/
remainderPlaced(remainder: Booking, parentReference: string): void {
const msg =
`The wagons left over from booking ${parentReference} have been booked as ` +
`${remainder.reference ?? remainder.id} on another train. ` +
`It carries its own invoice — pay it to secure that slot.`;
void this.notifyContact(remainder, msg, 'REMAINDER BOOKED');
this.inApp(remainder, 'Leftover wagons booked', msg, {
type: NotificationType.INVOICE_ISSUED,
priority: NotificationPriority.HIGH,
});
}
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
void (async () => {
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);

View File

@@ -0,0 +1,37 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { RecordCheckpointDto } from './record-checkpoint.dto';
const validateBody = (body: Record<string, unknown>) =>
validate(plainToInstance(RecordCheckpointDto, body));
describe('RecordCheckpointDto', () => {
// The final checkpoint arrives the schedule, so a backdated one rewrites the
// journey after the fact. No UI sends occurredAt; the endpoint still accepts it.
it('rejects a backdated occurredAt', async () => {
const errors = await validateBody({
sequenceNo: 3,
occurredAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
});
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('occurredAt');
expect(errors[0].constraints).toHaveProperty('IsNotBackdated');
});
it('accepts occurredAt of now', async () => {
const errors = await validateBody({
sequenceNo: 3,
occurredAt: new Date().toISOString(),
});
expect(errors).toHaveLength(0);
});
it('accepts a body that omits occurredAt, leaving the service to stamp it', async () => {
const errors = await validateBody({ sequenceNo: 0 });
expect(errors).toHaveLength(0);
});
});

View File

@@ -10,6 +10,8 @@ import {
Min,
} from 'class-validator';
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt()
@@ -21,9 +23,18 @@ export class RecordCheckpointDto {
@IsEnum(TrainCheckpointKind)
kind?: TrainCheckpointKind;
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
/**
* A checkpoint records where the train is as staff observe it, and the final
* one arrives the schedule — so a backdated value rewrites the journey after
* the fact. Only "now" is accepted; omit the field and the service stamps it.
*/
@ApiProperty({
required: false,
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
})
@IsOptional()
@IsISO8601()
@IsNotBackdated()
occurredAt?: string;
@ApiProperty({ required: false })

View File

@@ -67,10 +67,18 @@ export class IntercityService {
ts.status AS "scheduleStatus",
oy.id AS "originYardId",
COALESCE(oy.label, oy.code) AS "origin",
oy.has_facility AS "originHasFacility",
-- Can that end actually handle THIS booking's cargo? A container
-- booking needs a facility with a stacker; bulk needs any facility.
(oy.has_facility AND COALESCE(
CASE WHEN b.freight_type = 'CONTAINER'
THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
AS "originHasFacility",
dy.id AS "destinationYardId",
COALESCE(dy.label, dy.code) AS "destination",
dy.has_facility AS "destinationHasFacility",
(dy.has_facility AND COALESCE(
CASE WHEN b.freight_type = 'CONTAINER'
THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
AS "destinationHasFacility",
-- Where the train actually is, so the operator knows if the cargo
-- can be worked right now.
cp.yard_id AS "trainAtYardId",
@@ -80,6 +88,10 @@ export class IntercityService {
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.yard_facilities ofac
ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
LEFT JOIN freight.yard_facilities dfac
ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
LEFT JOIN freight.train_schedules ts
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
LEFT JOIN LATERAL (
@@ -104,7 +116,8 @@ export class IntercityService {
async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
const milestones = await this.routeMilestones(schedule);
const milestoneSeq = this.milestoneSequenceOf(schedule, milestones);
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
const waiting = milestoneSeq
@@ -112,29 +125,45 @@ export class IntercityService {
: [];
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
// Mid-corridor intercity matching needs a real stop list (>= 2 route
// milestones). Without one the fallback is a 2-stop origin->destination
// pseudo-route that only matches bookings on the train's exact corridor —
// surface that so an empty candidate list isn't misread as "nobody waiting".
const warning =
milestoneSeq == null
? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.'
: schedule.routeId && milestones.length < 2
? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear."
: null;
return {
scheduleId,
routeId: schedule.routeId ?? null,
warning,
// 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(
// legForYards, not legOf: on a built train the budget is a single
// whole-route edge (see intercityCapacity), so a mid-corridor booking
// must draw from that one pool via the whole-route fallback. On a
// locomotive-derived schedule it still resolves to the booking's own leg.
const leg = capacity?.budget.legForYards(
booking.originYardId,
booking.destinationYardId,
);
return {
...this.mapBooking(booking),
...this.mapBooking(booking, need),
need,
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
};
}),
accepted: accepted.map((booking) => ({
...this.mapBooking(booking),
need: capacity?.needFor(booking) ?? null,
})),
accepted: accepted.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return { ...this.mapBooking(booking, need), need };
}),
};
}
@@ -186,14 +215,17 @@ export class IntercityService {
continue;
}
const need = capacity.needFor(booking);
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)) {
// legForYards, not legOf: a built train's budget is a single whole-route
// pool (mid-corridor wagons are committed for the whole trip and never
// reloaded), so the booking draws from that pool via the whole-route
// fallback; a locomotive-derived schedule still gets the booking's own
// leg, so it can still board a train that is full only on other legs.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
if (!budget.fits(need, leg)) {
rejected.push({
bookingId,
reason:
'Does not fit the remaining wagon/weight/length capacity on its leg',
'Does not fit the remaining wagon/weight/length capacity for this train',
});
continue;
}
@@ -244,16 +276,21 @@ export class IntercityService {
* so an intercity booking exactly matching the train's own corridor still
* qualifies.
*/
private async routeMilestoneSequence(
private async routeMilestones(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
): Promise<RouteMilestone[]> {
if (!schedule.routeId) return [];
return this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
}
private milestoneSequenceOf(
schedule: TrainSchedule,
milestones: RouteMilestone[],
): Map<string, number> | null {
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
if (schedule.originStationId && schedule.destinationStationId) {
return new Map([
@@ -264,6 +301,15 @@ export class IntercityService {
return null;
}
private async routeMilestoneSequence(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
return this.milestoneSequenceOf(
schedule,
await this.routeMilestones(schedule),
);
}
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
private async findWaitingIntercityBookings(
milestoneSeq: Map<string, number>,
@@ -356,7 +402,12 @@ export class IntercityService {
return { schedule, booking };
}
private mapBooking(booking: Booking) {
/**
* `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is
* spent in. Prefer it, so the row's weight sits on the same axis as the
* remaining-capacity figure shown beside it; cargo VGM is the fallback.
*/
private mapBooking(booking: Booking, need?: { weightTons: number } | null) {
return {
id: booking.id,
reference: booking.reference,
@@ -372,7 +423,7 @@ export class IntercityService {
booking.destinationYard?.label ??
booking.destinationYard?.code ??
'Unknown destination',
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
};
}

View File

@@ -0,0 +1,211 @@
import { RemainderPlacementService } from './remainder-placement.service';
/**
* The remainder placer reconstructs the outstanding split remainder as a new
* booking. The delicate parts under test: bulk sizes from the outstanding tons;
* container recovers real numbers from the SOFT-DELETED units (never fabricates)
* and throws on a shortfall; and nothing is placed when there's no outstanding
* or no fitting train.
*/
describe('RemainderPlacementService', () => {
const DAY = '2026-07-20';
function make(opts: {
freightType: 'CONTAINER' | 'BULK';
contractKind?: 'ONE_TIME' | 'GENERAL';
outstanding: unknown;
createThrows?: Error;
deferredUnits?: Array<{
containerNumber: string;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
}>;
fittingTrains?: Array<{ scheduleId: string }>;
}) {
const contract = {
id: 'c-1',
freightType: opts.freightType,
contractKind: opts.contractKind ?? 'ONE_TIME',
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
};
const createUnderContract = opts.createThrows
? jest.fn().mockRejectedValue(opts.createThrows)
: jest
.fn()
.mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] });
const contractBookingService = {
splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding),
createUnderContract,
};
const bookingBatchService = {
fittingTrainsForDay: jest
.fn()
.mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]),
};
// getRepository is only hit on the container path (recoverDeferredUnits).
const lineRepo = {
find: jest.fn().mockResolvedValue([{ id: 'line-1' }]),
};
const unitRepo = {
find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) => {
const n = entity?.name ?? '';
if (n.includes('Unit')) return unitRepo;
return lineRepo;
}),
};
const notifier = { remainderPlaced: jest.fn() };
const service = new RemainderPlacementService(
dataSource as never,
contractsRepository as never,
contractBookingService as never,
bookingBatchService as never,
notifier as never,
);
return {
service,
createUnderContract,
contractBookingService,
bookingBatchService,
notifier,
};
}
const splitBooking = {
id: 'bk-1',
reference: 'BKG-1',
contractId: 'c-1',
scheduledDate: new Date('2026-07-20T06:00:00Z'),
createdByUserId: 'u-1',
} as never;
it('sizes a BULK remainder from the outstanding tons', async () => {
const { service, createUnderContract } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBe('rem-1');
const dto = createUnderContract.mock.calls[0][1];
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]);
expect(dto.scheduledDate).toBe(DAY);
});
it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => {
const deferredUnits = [
{ containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true },
{ containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true },
];
const { service, createUnderContract } = make({
freightType: 'CONTAINER',
outstanding: {
bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]),
bulk: null,
},
deferredUnits,
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBe('rem-1');
const dto = createUnderContract.mock.calls[0][1];
expect(dto.containers).toHaveLength(1);
const line = dto.containers[0];
expect(line.containerSize).toBe('40ft');
expect(line.quantity).toBe(2);
expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([
'ABCD1234567',
'ABCD7654321',
]);
expect(line.reeferQuantity).toBe(1);
expect(line.hazardousQuantity).toBe(1);
});
it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => {
const { service, createUnderContract } = make({
freightType: 'CONTAINER',
outstanding: {
bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]),
bulk: null,
},
deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBeNull();
expect(createUnderContract).not.toHaveBeenCalled();
});
it('is a no-op when there is no outstanding remainder', async () => {
const { service, createUnderContract } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } },
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBeNull();
expect(createUnderContract).not.toHaveBeenCalled();
});
it('tells the customer the leftover wagons were booked on another train', async () => {
const { service, notifier } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
});
await service.placeRemainder(splitBooking);
expect(notifier.remainderPlaced).toHaveBeenCalledWith(
expect.objectContaining({ id: 'rem-1' }),
'BKG-1',
);
});
it('never double-books the leftover when two payments land together', async () => {
const { service, createUnderContract } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
});
// Both callers enter before either create commits.
await Promise.all([
service.placeRemainder(splitBooking),
service.placeRemainder(splitBooking),
]);
expect(createUnderContract).toHaveBeenCalledTimes(1);
});
// splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's
// snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and
// either drops a real remainder or double-draws the cap, so we must not place.
it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => {
const { service, createUnderContract, contractBookingService } = make({
freightType: 'BULK',
contractKind: 'GENERAL',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBeNull();
expect(createUnderContract).not.toHaveBeenCalled();
expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled();
});
// The paid booking has already boarded — a create-gate rejection (e.g. the
// export whole-train gate) must leave the remainder rebookable, not escape.
it('swallows a create rejection and leaves the remainder for manual rebook', async () => {
const { service } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
createThrows: new Error('Not enough train space for this day.'),
});
await expect(service.placeRemainder(splitBooking)).resolves.toBeNull();
});
it('is a no-op when the contract has no split chain', async () => {
const { service, createUnderContract } = make({
freightType: 'BULK',
outstanding: null,
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBeNull();
expect(createUnderContract).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,342 @@
import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common';
import { DataSource, IsNull, Not } from 'typeorm';
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 {
ContractBookingService,
SplitOutstanding,
} from '../contracts/contract-booking.service';
import { ContractsRepository } from '../contracts/contracts.repository';
import {
CreateBookingUnderContractDto,
CreateContainerUnitDto,
} from '../contracts/dto/create-booking-under-contract.dto';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service';
import { eatDay } from './batch-window.util';
/**
* Auto-creates and places the OUTSTANDING split remainder of a contract as a new
* booking, so the customer doesn't have to manually rebook the wagons that did
* not fit the train they just paid for.
*
* Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only
* once the customer has actually accepted+paid the offered part. Before payment
* nothing is split: the booking stays whole and the customer may still edit or
* cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}.
*
* IMPORT/DOMESTIC: the remainder booking is created with the next fitting
* shipment day set and then follows the normal windowed batch flow (train
* assigned at window close, paid in its own window). It is NOT force-reserved on
* a specific train — import is not FCFS.
*
* Container reconstruction is HYBRID: the remainder's quantities come from the
* split snapshot (`splitOutstanding`), but the actual container numbers / VGM /
* seals are read back from the units `applySplit` SOFT-DELETED off the parent
* (they survive as valid ISO records). We never `restore()` those rows — the new
* booking gets fresh rows — so the contract cap is never double-counted.
*/
@Injectable()
export class RemainderPlacementService {
private readonly logger = new Logger(RemainderPlacementService.name);
constructor(
private readonly dataSource: DataSource,
private readonly contractsRepository: ContractsRepository,
@Inject(forwardRef(() => ContractBookingService))
private readonly contractBookingService: ContractBookingService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly notifier: BookingNotifierService,
) {}
/**
* Create + place the outstanding split remainder of the contract that owns
* `splitBooking`. No-op when there is no live remainder or no fitting day.
* Returns the created remainder booking id, or null when nothing was placed
* (residual falls back to the customer's manual rebook, as today).
*/
async placeRemainder(splitBooking: Booking): Promise<string | null> {
if (!splitBooking.contractId) return null;
// Two payment webhooks for the same contract landing together would both see
// the remainder as unbooked (the placing create has not committed yet) and
// each create one — double-booking the leftover. Serialize per contract: the
// second caller returns immediately and the first one's create is what the
// (now smaller) outstanding reflects.
if (this.inFlight.has(splitBooking.contractId)) {
this.logger.debug(
`Remainder placement already running for contract ${splitBooking.contractId} — skipped.`,
);
return null;
}
this.inFlight.add(splitBooking.contractId);
try {
return await this.placeRemainderInner(splitBooking);
} finally {
this.inFlight.delete(splitBooking.contractId);
}
}
/** Contracts with a placement in flight — see {@link placeRemainder}. */
private readonly inFlight = new Set<string>();
private async placeRemainderInner(
splitBooking: Booking,
): Promise<string | null> {
const contract = await this.contractsRepository.findByIdWithRelations(
splitBooking.contractId!,
);
if (!contract) return null;
// ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total
// from a SINGLE booking's pre-split snapshot, which is only coherent when
// the contract has exactly one live chain — that is the ONE_TIME invariant
// (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL
// contract with other live bookings the subtraction mixes scopes: it either
// clamps to 0 and silently drops a real remainder, or sizes one that then
// draws the quantity cap a second time. GENERAL remainders keep the existing
// manual-rebook behaviour until the remainder can be derived from the
// offer's own dropped lines rather than from the contract-wide ledger.
if (contract.contractKind !== 'ONE_TIME') {
this.logger.debug(
`Contract ${contract.id} is ${contract.contractKind} — remainder left ` +
`for manual rebook (auto-placement is ONE_TIME only).`,
);
return null;
}
const outstanding = await this.contractBookingService.splitOutstanding(
contract,
);
if (!outstanding || !this.hasOutstanding(contract, outstanding)) {
return null;
}
// The next fitting day: the earliest day on/after the split booking's own day
// that still has an import train with room for this cargo type. We reuse the
// split booking as the capacity probe — it carries the leg + cargo relations.
const day = await this.nextFittingDay(splitBooking);
if (!day) {
this.logger.warn(
`No train with room for the remainder of contract ${contract.id} ` +
`(booking ${splitBooking.reference}) — left for manual rebook.`,
);
return null;
}
let dto: CreateBookingUnderContractDto;
try {
dto = await this.buildRemainderDto(
contract,
outstanding,
splitBooking.id,
day,
);
} catch (err) {
// A reconstruction shortfall (fewer recoverable units than outstanding)
// must NOT fabricate container numbers — fail loudly, leave manual rebook.
this.logger.error(
`Could not reconstruct the remainder of contract ${contract.id}: ` +
`${err instanceof Error ? err.message : String(err)} — left for manual rebook.`,
);
return null;
}
// Any create-gate rejection (no train space, cap, container clash) must not
// escape: the customer's paid booking has already boarded, and a thrown
// error here would only be logged upstream while the remainder vanished
// silently. Fall back to leaving it rebookable, which is the pre-feature
// behaviour, and say so in the log.
let created: Awaited<
ReturnType<ContractBookingService['createUnderContract']>
>;
try {
created = await this.contractBookingService.createUnderContract(
contract.id,
dto,
{ id: splitBooking.createdByUserId ?? undefined },
// System actor: a permission-bag carrying the contract create-booking key
// so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B
// contracts; harmless for customer (Path A) contracts.
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
);
} catch (err) {
this.logger.error(
`Could not create the remainder booking for contract ${contract.id} ` +
`(from ${splitBooking.reference}): ${
err instanceof Error ? err.message : String(err)
} — left for manual rebook.`,
);
return null;
}
// EXPORT is FCFS — there is no window to wait for, so the remainder is
// reserved on the next export train right away (its own pay window opens).
// If it does not fit one train whole either, the export accept offers it a
// partial and the chain repeats on ITS payment: each pass leaves a strictly
// smaller remainder, so it terminates at the day's train count.
// IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next
// fitting day and rides the normal windowed batch flow.
if (splitBooking.tradeDirection === 'EXPORT') {
const fresh = await this.dataSource
.getRepository(Booking)
.findOne({
where: { id: created.booking.id },
relations: {
company: true,
bookingContainers: { containerType: true },
cargoType: true,
},
});
if (fresh) {
await this.bookingBatchService
.acceptExportBooking(fresh)
.catch((err) =>
// No export train took it — it stays created and rebookable, which
// is the same place a customer-driven rebook would leave it.
this.logger.warn(
`Export remainder ${fresh.reference} created but not reserved: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
}
this.notifier.remainderPlaced(
created.booking,
splitBooking.reference ?? splitBooking.id,
);
this.logger.log(
`Auto-placed split remainder of contract ${contract.id} as booking ` +
`${created.booking.reference} on ${day}.`,
);
return created.booking.id;
}
private hasOutstanding(
contract: Contract,
outstanding: SplitOutstanding,
): boolean {
if (contract.freightType === 'CONTAINER') {
return [...outstanding.bySize.values()].some((s) => s.outstanding > 0);
}
return (outstanding.bulk?.outstanding ?? 0) > 0.001;
}
/**
* The shipment day to create the remainder on — the split booking's own day.
*
* EXPORT is FCFS and must actually board a train that day, so a day with NO
* export train having room is rejected (null → left for manual rebook on a day
* the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is
* assigned by the batch engine at window close, not now, and the window may
* still free up — forcing a different day here would override the customer's
* binding shipment day.
*/
private async nextFittingDay(booking: Booking): Promise<string | null> {
if (!booking.scheduledDate) return null;
const day = eatDay(new Date(booking.scheduledDate));
if (booking.tradeDirection !== 'EXPORT') return day;
const fitting = await this.bookingBatchService.fittingTrainsForDay(
booking,
day,
'EXPORT',
);
return fitting.length > 0 ? day : null;
}
/**
* Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the
* outstanding tonnage directly. Container reads the deferred (soft-deleted)
* units of the split booking back into real unit records.
*/
private async buildRemainderDto(
contract: Contract,
outstanding: SplitOutstanding,
splitBookingId: string,
day: string,
): Promise<CreateBookingUnderContractDto> {
const dto: CreateBookingUnderContractDto = { scheduledDate: day };
if (contract.freightType !== 'CONTAINER') {
const tons = outstanding.bulk?.outstanding ?? 0;
dto.bulkLines = [{ cargoWeightTons: tons }];
return dto;
}
// Container: recover the deferred units per size from the split booking's
// soft-deleted rows and reshape into DTO units.
const containers: NonNullable<CreateBookingUnderContractDto['containers']> = [];
for (const [size, { outstanding: need }] of outstanding.bySize) {
if (need <= 0) continue;
const units = await this.recoverDeferredUnits(splitBookingId, size, need);
if (units.length < need) {
throw new Error(
`size ${size}: recovered ${units.length} deferred container(s) but ` +
`${need} are outstanding`,
);
}
const line: NonNullable<CreateBookingUnderContractDto['containers']>[number] = {
containerSize: size,
quantity: need,
units,
};
line.hazardousQuantity = units.filter((u) => u.isHazardous).length;
line.reeferQuantity = units.filter((u) => u.isReefer).length;
containers.push(line);
}
dto.containers = containers;
return dto;
}
/**
* The `need` deferred container units of a given size for the split booking,
* read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the
* LIFO trim in applySplit so the same physical containers deferred are the
* ones rebooked). Returns them as DTO units; does NOT restore the rows.
*/
private async recoverDeferredUnits(
splitBookingId: string,
containerSize: string,
need: number,
): Promise<CreateContainerUnitDto[]> {
// The line ids of this booking for this size (live + soft-deleted): units
// key on bookingContainerId, so gather every line of the size first.
const lines = await this.dataSource
.getRepository(BookingContainer)
.find({
where: { bookingId: splitBookingId, containerSize },
withDeleted: true,
select: { id: true },
});
const lineIds = lines.map((l) => l.id);
if (!lineIds.length) return [];
// Only the DELETED units are the deferred ones (live units stayed on the
// paid part). Oldest-first to match the deferred set.
const deferred = await this.dataSource
.getRepository(BookingContainerUnit)
.find({
where: lineIds.map((bookingContainerId) => ({
bookingContainerId,
deletedAt: Not(IsNull()),
})),
withDeleted: true,
order: { sortOrder: 'ASC', createdAt: 'ASC' },
take: need,
});
return deferred.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? undefined,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
}));
}
}

View File

@@ -139,7 +139,8 @@ export class TrainSchedulingController {
@Get("batch-board/:scheduleId")
@TrainSchedulingView()
@ApiOperation({
summary: "Batch board detail for one schedule with EAT 3h windows",
summary:
"Batch board detail for one schedule: its booking window, in-window bookings and pending-contract bucket",
})
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
return this.bookingBatchService.getBatchBoardDetail(scheduleId);

View File

@@ -34,6 +34,7 @@ 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 { RemainderPlacementService } from './remainder-placement.service';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -82,6 +83,7 @@ import { ContractsModule } from '../contracts/contracts.module';
WsAuthService,
BookingWindowService,
BookingSplitService,
RemainderPlacementService,
IntercityService,
BookingJourneyService,
FacilityHandlingService,

View File

@@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => {
expect(result).toHaveLength(2);
});
});
describe('marshalling documents', () => {
// Staff check these against the physical consist, so every wagon on the
// train set has to appear — an empty wagon that renders no row reads as a
// wagon that is not on the train.
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
sequenceNo,
wagonNumber,
physicalWagon: { wagonNumber },
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
lengthMeters: 14,
capacityTons: 70,
allocations,
});
const loadedAllocation = {
bookingId: 'booking-1',
bookingReference: 'BK-2026-000001',
loadType: 'CONTAINER',
allocatedWeightTons: 24.5,
containerNumbers: ['CONT-001'],
booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
};
const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;
it('lists an empty wagon on the export document and marks it EMPTY', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: {
wagons: [
makeWagon(1, 'W-001', [loadedAllocation]),
makeWagon(2, 'W-002', []),
makeWagon(3, 'W-003', []),
],
},
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(countRows(html)).toBe(3);
expect(html).toContain('W-002');
expect(html).toContain('W-003');
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
// The wagon count must agree with the rows the reader can see.
expect(html).toContain('3 (2 empty)');
});
it('lists an empty wagon on the import document and marks it EMPTY', () => {
const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1',
trainNumber: '8002',
route: 'Djibouti → Indode',
origin: 'Djibouti Port',
destination: 'Indode',
totalBookings: 1,
wagons: [
{ sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
{ sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
],
operation: { status: {} },
};
const html = (service as never as {
buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList);
expect(countRows(html)).toBe(2);
expect(html).toContain('W-002');
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
expect(html).toContain('2 (1 empty)');
});
it('renders wagons in consist order regardless of the order the relation returns', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: {
wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
},
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
});
it('omits the empty-count suffix when every wagon is loaded', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(html).not.toContain('empty)');
expect(html).not.toContain('EMPTY');
});
});
});

View File

@@ -267,6 +267,8 @@ export interface CompositionUnassignedBookingRow {
freightType: string | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: string | null;
wagonsRequired: number;
@@ -477,6 +479,38 @@ export class TrainSchedulingService {
return qb.getMany();
}
/**
* A built train makes at most ONE departure per route per EAT day. Returns
* the non-cancelled schedule already holding this train on this route for
* `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
* legal — siblings must be different trains.
*/
private async findTrainRouteDayConflict(
trainId: string,
routeId: string,
departure: Date,
excludeScheduleId?: string,
): Promise<TrainSchedule | null> {
const day = eatDay(departure);
const dayStart = eatDayToUtc(day, 0);
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
const qb = this.dataSource
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.innerJoin('s.trainSet', 'ts')
.where('ts.trainId = :trainId', { trainId })
.andWhere('s.routeId = :routeId', { routeId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
return qb.getOne();
}
/**
* The window timeline a brand-new schedule must adopt to join its route+day
* group. Returns the canonical open/close times + rule snapshot copied from an
@@ -880,6 +914,28 @@ export class TrainSchedulingService {
);
}
// Moving onto a day where this same built train already runs this route
// would double-book the physical train — blocked for planning moves.
if (schedule.trainSetId && schedule.routeId) {
const trainSet = await this.dataSource
.getRepository(TrainSet)
.findOne({ where: { id: schedule.trainSetId } });
if (trainSet?.trainId) {
const conflict = await this.findTrainRouteDayConflict(
trainSet.trainId,
schedule.routeId,
departure,
id,
);
if (conflict) {
throw new ConflictException(
`This train is already scheduled on this route for that day ` +
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
);
}
}
}
// Re-derive the window from the schedule's own rule snapshot (falling back to
// the live config where a legacy row has no snapshot) against the new date.
const merged = effectiveWindowConfig(schedule, windowCfg);
@@ -913,10 +969,37 @@ export class TrainSchedulingService {
scheduledDepartureDate: departure,
...windowFields,
});
// Only customers whose bookings already HOLD wagons on this train are told
// about the move (SMS + email + portal inbox). Linked-but-unallocated
// bookings are skipped — nothing of theirs is riding this departure yet.
let notifiedCount = 0;
if (schedule.trainSetId) {
const allocations = await this.dataSource
.getRepository(WagonBookingAllocation)
.createQueryBuilder('a')
.innerJoin('a.trainSetWagon', 'slot')
.where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
.getMany();
const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
if (allocatedBookingIds.length) {
const allocatedBookings = await this.dataSource.getRepository(Booking).find({
where: { id: In(allocatedBookingIds) },
relations: { company: true },
});
for (const booking of allocatedBookings) {
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
this.bookingNotifier.rescheduled(booking, departure);
notifiedCount += 1;
}
}
}
this.logger.log(
`Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
`${notifiedCount} allocated customer booking(s) notified`,
);
void this.emitWindowState(id);
@@ -1200,7 +1283,8 @@ export class TrainSchedulingService {
}
if (
builtTrain.status === Freight.TrainStatus.OutOfService ||
builtTrain.status === Freight.TrainStatus.UnderMaintenance
builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
builtTrain.status === Freight.TrainStatus.Deactivated
) {
throw new ConflictException(
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
@@ -1220,6 +1304,17 @@ export class TrainSchedulingService {
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
new Date(dto.scheduleDate),
);
if (conflict) {
throw new ConflictException(
`Train ${builtTrain.code} is already scheduled on this route for that day ` +
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
);
}
} else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 2) {
@@ -1679,6 +1774,7 @@ export class TrainSchedulingService {
scheduleId,
schedule.originStationId,
savedWagons,
schedule.reverseWagonOrder ?? false,
);
});
@@ -2590,19 +2686,24 @@ export class TrainSchedulingService {
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
totalBookings: schedule.scheduleBookings?.length ?? 0,
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
// Every wagon on the train set, loaded or not, in consist order. An empty
// wagon has an empty `allocations` array — it is still part of the train
// and still belongs on the marshalling document.
wagons: [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
})),
})),
})),
operation: await this.getImportDjiboutiOperation(schedule.id),
};
}
@@ -2652,9 +2753,33 @@ export class TrainSchedulingService {
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
const rows = (schedule.trainSet?.wagons ?? [])
.flatMap((wagon) =>
(wagon.allocations ?? []).map((allocation) => {
// The document is checked against the physical train, so it has to run in
// consist order — the relation comes back unordered.
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
);
const rows = wagons
.flatMap((wagon) => {
// Wagon identity is the same on every row the wagon produces, loaded or not.
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
const allocations = wagon.allocations ?? [];
// An empty wagon still runs in the consist, so it still gets a line. Staff
// check this document against the physical train — a wagon with no row
// reads as a wagon that is not there, and the count stops matching.
if (allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="6">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const company = booking?.company as Record<string, unknown> | null | undefined;
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
@@ -2664,12 +2789,7 @@ export class TrainSchedulingService {
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr>
<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
${wagonCells}
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
<td>${esc(booking?.companyId)}</td>
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
@@ -2677,10 +2797,11 @@ export class TrainSchedulingService {
<td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td>
</tr>`;
}),
)
});
})
.join('');
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
const totalWeight = wagons.reduce(
(sum, wagon) =>
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
@@ -2708,6 +2829,8 @@ export class TrainSchedulingService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tr.empty td { background: #f8fafc; color: #64748b; }
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
@@ -2735,7 +2858,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
@@ -2759,7 +2882,7 @@ export class TrainSchedulingService {
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
</tbody>
</table>
@@ -2802,19 +2925,31 @@ export class TrainSchedulingService {
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
const allocationRows = loadList.wagons
.flatMap((wagon) =>
wagon.allocations.map(
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>`;
// An empty wagon still runs in the consist, so it still gets a line — see
// buildExportLoadListHtml.
if (wagon.allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return wagon.allocations.map(
(allocation) => `<tr>
<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`,
),
)
);
})
.join('');
return `<!doctype html>
@@ -2846,6 +2981,8 @@ export class TrainSchedulingService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
.num { text-align: right; }
tr.empty td { background: #f8fafc; color: #64748b; }
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
@@ -2872,7 +3009,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
@@ -2900,7 +3037,7 @@ export class TrainSchedulingService {
</tr>
</thead>
<tbody>
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
</tbody>
</table>
@@ -3866,11 +4003,18 @@ export class TrainSchedulingService {
}
const totalWeightTons = totalAssignedWeight(fittingBookings);
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
// figure spent against it must be gross too — cargo alone under-reports the
// train by the full consist tare and disagrees with the assign path.
const totalTareTons = roundTons(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (grossWeightTons > trainLimits.maxWeightTons) {
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]);
}
@@ -3897,7 +4041,7 @@ export class TrainSchedulingService {
if (
setLimits &&
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
totalWeightTons ||
grossWeightTons ||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
totalLengthMeters)
) {
@@ -3918,7 +4062,7 @@ export class TrainSchedulingService {
!inServiceLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
totalWeightTons &&
grossWeightTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
totalLengthMeters,
)
@@ -3940,6 +4084,9 @@ export class TrainSchedulingService {
summary: {
totalBookings: fittingBookings.length,
totalWeightTons,
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons,
totalTareTons,
// Human-readable wagon type(s) of the plan — mixed consists list all.
wagonType: plannedTypeCodes.join('/') || 'NONE',
wagonsNeeded: wagonPlan.length,
@@ -4271,6 +4418,7 @@ export class TrainSchedulingService {
scheduleId: string,
originYardId: string,
slots: TrainSetWagon[],
reverseWagonOrder = false,
) {
const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find();
@@ -4316,6 +4464,7 @@ export class TrainSchedulingService {
assignedPhysicalIds,
builtTrainId,
pinnedToScheduleIds,
reverseWagonOrder,
);
if (!physical) continue;
@@ -4409,6 +4558,7 @@ export class TrainSchedulingService {
assignedPhysicalIds: Set<string>,
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
): Wagon | undefined {
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
@@ -4429,12 +4579,26 @@ export class TrainSchedulingService {
// wherever they currently sit (they travel with the train), never a loose
// yard wagon.
if (builtTrainId) {
return wagons.find(
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
!assignedPhysicalIds.has(w.id),
);
// Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
// consist views draw the schedule exactly like the train builder; a schedule
// created with reverseWagonOrder pins back-to-front (physically-last wagon
// takes slot #1). Unsequenced wagons sort after every sequenced one.
const candidates = wagons
.filter(
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
!assignedPhysicalIds.has(w.id),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
}
return reverseWagonOrder
? b.sequenceNumber - a.sequenceNumber
: a.sequenceNumber - b.sequenceNumber;
});
return candidates[0];
}
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
// fall back to one riding from the train's origin.
@@ -5097,7 +5261,11 @@ export class TrainSchedulingService {
const trains = await this.dataSource.getRepository(Train).find({
where: {
status: Not(
In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]),
In([
Freight.TrainStatus.OutOfService,
Freight.TrainStatus.UnderMaintenance,
Freight.TrainStatus.Deactivated,
]),
),
},
relations: {
@@ -5497,8 +5665,8 @@ export class TrainSchedulingService {
* Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE)
* keep their status — staff own that flag, not the scheduler.
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
* / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
*/
private async syncBuiltTrainAfterScheduleChange(
manager: EntityManager,
@@ -6208,11 +6376,21 @@ export class TrainSchedulingService {
* engine's representative fallbacks for bookings whose cargo/container type
* has no wagon type configured. Loaded once per request before mapping.
*/
/** Wagon types are near-static reference data — a short TTL cache spares one
* table scan per detail/board request without letting edits go stale long. */
private wagonTareDimsCache: {
value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
expiresAt: number;
} | null = null;
private async loadWagonTareDims(): Promise<{
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
bulk: { tareWeightTons: number; capacityTons: number };
container: { tareWeightTons: number; capacityTons: number };
}> {
if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
return this.wagonTareDimsCache.value;
}
const types = await this.dataSource.getRepository(WagonType).find();
const byWagonTypeId = new Map(
types.map((t) => [
@@ -6223,7 +6401,7 @@ export class TrainSchedulingService {
},
]),
);
return {
const value = {
byWagonTypeId,
bulk: {
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
@@ -6234,6 +6412,8 @@ export class TrainSchedulingService {
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
},
};
this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
@@ -6290,49 +6470,14 @@ export class TrainSchedulingService {
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
// Booking weights are reported GROSS (cargo + wagon tare) — the number the
// locomotive actually hauls and the axis its pull limit is compared against.
const tareDims = await this.loadWagonTareDims();
// Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure
// loading gate, so the workspace shows the confirm button as already done.
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
let loadingConfirmed = !requiresLoadingConfirmation;
if (requiresLoadingConfirmation) {
const op = await this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: schedule.id } });
loadingConfirmed = Boolean(op?.loadedOnTrainAt);
}
const windowCfg = await this.getWindowConfig();
const [containerItems, bulkLoads] = await Promise.all([
allocationIds.length
? this.wagonAllocationContainerItemsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { containerType: true, bookingContainer: true },
})
: [],
allocationIds.length
? this.wagonAllocationBulkLoadsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { cargoType: true },
})
: [],
]);
const containerItemsByAllocation = new Map<string, typeof containerItems>();
for (const item of containerItems) {
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
list.push(item);
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
}
const bulkLoadsByAllocation = new Map(
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
// Snapshot state decides below whether the live consist may be drawn at
// all, so it is derived before the consist wagons are fetched.
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
// and re-pinned onto later trains — the live wagon↔slot joins no longer
// describe THIS train. If a frozen snapshot was captured at the transition,
@@ -6347,6 +6492,55 @@ export class TrainSchedulingService {
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
// All independent lookups fired at once — they used to run one after
// another, stacking round-trips onto every detail request.
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
// number the locomotive actually hauls against its pull limit.
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
await Promise.all([
this.loadWagonTareDims(),
requiresLoadingConfirmation
? this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: schedule.id } })
: null,
this.getWindowConfig(),
allocationIds.length
? this.wagonAllocationContainerItemsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { containerType: true, bookingContainer: true },
})
: [],
allocationIds.length
? this.wagonAllocationBulkLoadsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { cargoType: true },
})
: [],
schedule.trainSet?.trainId && !isWagonAllocationFrozen
? this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
// Mirror the pinning direction: a reverse-order schedule draws the
// whole consist back-to-front, empties included.
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
})
: [],
]);
const loadingConfirmed = requiresLoadingConfirmation
? Boolean(importOp?.loadedOnTrainAt)
: true;
const containerItemsByAllocation = new Map<string, typeof containerItems>();
for (const item of containerItems) {
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
list.push(item);
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
}
const bulkLoadsByAllocation = new Map(
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
// The trainSet slots below are the PLANNED wagons (one per allocation). A
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
// included (the pull-limit check already counts their tare) — so append the
@@ -6369,41 +6563,32 @@ export class TrainSchedulingService {
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
);
const emptyConsistWagons =
schedule.trainSet?.trainId && !isWagonAllocationFrozen
? (
await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
})
)
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: 'EMPTY',
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}))
: [];
const emptyConsistWagons = rawConsistWagons
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: 'EMPTY',
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}));
return {
id: schedule.id,
@@ -6413,6 +6598,7 @@ export class TrainSchedulingService {
trainNumber: schedule.trainNumber ?? null,
maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null,
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
requiresLoadingConfirmation,
loadingConfirmed,
// Booking-window phase + phase deadlines drive the countdown timers in the
@@ -6713,8 +6899,13 @@ export class TrainSchedulingService {
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule(
scheduleId: string,
// Callers that already hold the full schedule graph (batch board detail)
// pass it in so the preview doesn't re-load the same heavy graph.
preloadedSchedule?: TrainSchedule,
): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
@@ -6761,7 +6952,10 @@ export class TrainSchedulingService {
);
if (!eligible.length) return empty;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id);
const wagonAssignedIds = await this.getWagonAssignedBookingIds(
schedule.id,
schedule,
);
const previewDto = {
bookingIds: eligible.map((b) => b.id),
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
@@ -7036,6 +7230,15 @@ export class TrainSchedulingService {
shortfall: 0,
}));
// Gross weight needs the scheduling graph (containers, cargo type, wagon
// types) that the trimmed select above deliberately skips.
const tareDims = await this.loadWagonTareDims();
const fullById = new Map(
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
(b) => [b.id, b],
),
);
const bookings = await Promise.all(
unassigned.map(async (b) => {
const assignability = await this.previewUnassignedBookingAssignability(
@@ -7050,6 +7253,11 @@ export class TrainSchedulingService {
freightType: b.freightType ?? null,
priorityScore: b.priorityScore ?? 0,
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
// GROSS: cargo + tare of the wagons the booking occupies.
grossWeightTons: this.grossBookingWeightTons(
(fullById.get(b.id) ?? b) as Booking,
tareDims,
),
status: b.status ?? null,
schedulingStatus: b.schedulingStatus ?? null,
...assignability,
@@ -7278,8 +7486,15 @@ export class TrainSchedulingService {
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
}
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
private async getWagonAssignedBookingIds(
scheduleId: string,
// Pass when the caller already holds the schedule with trainSet.wagons —
// only wagon ids are read here, the old full-graph reload was pure waste.
preloadedSchedule?: TrainSchedule,
): Promise<Set<string>> {
const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.length) return new Set();

View File

@@ -44,6 +44,15 @@ export class TrainBuilderController {
return this.trainBuilderService.listBuilt(query);
}
// Must be declared before @Get(':id') so the path isn't captured as an id.
@Get('used-train-numbers')
@ApiOperation({
summary: 'Import/export run numbers already claimed by existing trains',
})
usedTrainNumbers() {
return this.trainBuilderService.usedTrainNumbers();
}
@Get(':id')
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
composition(@Param('id', ParseUUIDPipe) id: string) {
@@ -115,6 +124,22 @@ export class TrainBuilderController {
return this.trainBuilderService.reorderWagons(id, dto);
}
@Post(':id/deactivate')
@FleetManage()
@ApiOperation({
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
})
deactivate(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.deactivate(id);
}
@Post(':id/activate')
@FleetManage()
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
activate(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.activate(id);
}
@Delete(':id')
@FleetManage()
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -153,6 +153,38 @@ export class TrainBuilderService {
};
}
/**
* Run numbers already claimed by live (non-deleted) trains, split by
* direction. Legacy single `train_number` values are sorted into a side by
* parity (even = import, odd = export) so the pickers can grey them out too.
*/
async usedTrainNumbers() {
const rows: {
import_train_number: string | null;
export_train_number: string | null;
train_number: string | null;
}[] = await this.dataSource.query(
`SELECT import_train_number, export_train_number, train_number
FROM freight.trains
WHERE deleted_at IS NULL`,
);
const importTrainNumbers = new Set<string>();
const exportTrainNumbers = new Set<string>();
for (const row of rows) {
if (row.import_train_number) importTrainNumbers.add(row.import_train_number);
if (row.export_train_number) exportTrainNumbers.add(row.export_train_number);
const legacy = row.train_number?.trim();
if (legacy && /^\d+$/.test(legacy)) {
(Number(legacy) % 2 === 0 ? importTrainNumbers : exportTrainNumbers).add(legacy);
}
}
return {
importTrainNumbers: [...importTrainNumbers].sort(),
exportTrainNumbers: [...exportTrainNumbers].sort(),
};
}
/**
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
* else the earliest upcoming departure) — feeds the list's direction tint
@@ -532,6 +564,50 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Park the train indefinitely (status DEACTIVATED). Blocked while it still
* has a live (DRAFT/SCHEDULED/DISPATCHED) schedule. The consist stays
* coupled; like UNDER_MAINTENANCE / OUT_OF_SERVICE the flag is staff-owned —
* the scheduler never overwrites it and refuses the train for new schedules.
*/
async deactivate(id: string) {
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.Deactivated) return;
const active: { count: string }[] = await manager.query(
`SELECT COUNT(*)::text AS count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
[id],
);
if (Number(active[0]?.count ?? 0) > 0) {
throw new ConflictException(
'Train has active schedules; cancel them before deactivating the train',
);
}
await manager
.getRepository(Train)
.update(id, { status: Freight.TrainStatus.Deactivated });
});
return this.getComposition(id);
}
/** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */
async activate(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.Deactivated) {
await this.dataSource
.getRepository(Train)
.update(id, { status: Freight.TrainStatus.Available });
}
return this.getComposition(id);
}
/** Disband the train: release wagons and locomotives, then delete it. */
async disband(id: string): Promise<void> {
await this.dataSource.transaction(async (manager) => {

View File

@@ -0,0 +1,41 @@
/**
* EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The
* even IMPORT run (Djibouti → Ethiopia) is fixed by the export run.
*
* Run numbers are always 4 digits (8401, never 84001). Pairs are listed out
* rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks
* the convention stays correct here.
*
* SeedWagonRunNumbers2280000000000 carries its own frozen copy on purpose: a
* migration must keep doing what it did when it was applied, whereas this list
* is live config for the update script. Add or retire runs HERE.
*/
export const TRAIN_RUN_PAIRS: Record<string, string> = {
'8001': '8002',
'8101': '8102',
'8201': '8202',
'8301': '8302',
'8401': '8402',
'8501': '8502',
'8601': '8602',
'8701': '8702',
'8801': '8802',
'8901': '8902',
'9001': '9002',
};
/** Even IMPORT run -> its odd EXPORT run. Derived so the two cannot drift. */
export const EXPORT_BY_IMPORT: Record<string, string> = Object.fromEntries(
Object.entries(TRAIN_RUN_PAIRS).map(([exportRun, importRun]) => [importRun, exportRun]),
);
/**
* Normalise any run number to its EXPORT run. Accepts either half of a pair, so
* a sheet listing "8002" and one listing "8001" both resolve to the same train.
* Returns null when the number belongs to no known run.
*/
export const toExportRun = (run: string): string | null => {
const value = run.trim();
if (TRAIN_RUN_PAIRS[value]) return value;
return EXPORT_BY_IMPORT[value] ?? null;
};

View File

@@ -1926,7 +1926,8 @@ export class WarehouseInventoryService {
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
const [schedule] = await this.dataSource.query(
`SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry"
`SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId",
oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
@@ -1957,14 +1958,20 @@ export class WarehouseInventoryService {
tradeDirection: string | null;
cargoTypeCode: string | null;
}[] = await this.dataSource.query(
// Only bookings whose destination IS this train's final yard unload into
// this (final-destination) warehouse. A mid-corridor import that alighted
// at an intermediate yard was already unloaded there by the checkpoint
// auto-unload; without this filter it would be mis-located into the final
// yard's inventory too.
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
[scheduleId],
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND b.destination_yard_id = $2`,
[scheduleId, schedule.destinationStationId],
);
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;

View File

@@ -0,0 +1,174 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { AppDataSource } from '../data-source';
import { TRAIN_RUN_PAIRS, toExportRun } from '../modules/wagons/train-runs.const';
/**
* Update wagon run numbers from a roster file — the tool for making the DB match
* the operator's sheet.
*
* pnpm seed:wagon-runs <file.csv> [--apply]
*
* CSV: two columns, header optional. Either half of a run pair is accepted, so
* "8001" and "8002" both mean the same train.
*
* wagon_number,run
* ER0744,8001
* ER0458,8102
*
* FULL REPLACEMENT: wagons absent from the file have their runs cleared, so the
* DB ends up matching the file exactly rather than accumulating stale rows.
*
* Dry run by default — it validates and prints what would change. Nothing is
* written without `--apply`. Validation is fatal on: an unknown run, a wagon not
* in the database, or the same wagon claimed by two runs (a wagon holds one run,
* so a double-booking has no correct answer and must be fixed in the sheet).
*/
interface Row {
line: number;
wagonNumber: string;
exportRun: string;
}
function parseCsv(path: string) {
const text = readFileSync(path, 'utf8');
const rows: Row[] = [];
const unknownRuns: string[] = [];
text.split(/\r?\n/).forEach((raw, i) => {
const line = i + 1;
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith('#')) return;
const [rawWagon = '', rawRun = ''] = trimmed.split(',').map((c) => c.trim());
// Skip a header row without needing it to be declared.
if (/wagon/i.test(rawWagon) && /run|train/i.test(rawRun)) return;
if (!rawWagon || !rawRun) {
throw new Error(`line ${line}: expected "wagon_number,run", got "${trimmed}"`);
}
const exportRun = toExportRun(rawRun);
if (!exportRun) {
unknownRuns.push(`line ${line}: "${rawRun}" (wagon ${rawWagon})`);
return;
}
rows.push({ line, wagonNumber: rawWagon.toUpperCase(), exportRun });
});
return { rows, unknownRuns };
}
async function updateWagonRuns() {
const [fileArg, ...flags] = process.argv.slice(2);
const apply = flags.includes('--apply');
if (!fileArg) {
console.error('usage: pnpm seed:wagon-runs <file.csv> [--apply]');
process.exit(2);
}
const path = resolve(process.cwd(), fileArg);
const { rows, unknownRuns } = parseCsv(path);
// A wagon in two runs cannot be represented — surface every instance rather
// than silently keeping whichever line happened to come first.
const seen = new Map<string, Row>();
const doubleBooked: string[] = [];
for (const row of rows) {
const prior = seen.get(row.wagonNumber);
if (prior && prior.exportRun !== row.exportRun) {
doubleBooked.push(
`${row.wagonNumber}: run ${prior.exportRun} (line ${prior.line}) vs ${row.exportRun} (line ${row.line})`,
);
continue;
}
if (!prior) seen.set(row.wagonNumber, row);
}
await AppDataSource.initialize();
try {
const wagonNumbers = [...seen.keys()];
const existing: Array<{ wagon_number: string }> = wagonNumbers.length
? await AppDataSource.query(
`SELECT wagon_number FROM freight.wagons
WHERE deleted_at IS NULL AND wagon_number = ANY($1::text[]);`,
[wagonNumbers],
)
: [];
const known = new Set(existing.map((r) => r.wagon_number));
const missing = wagonNumbers.filter((w) => !known.has(w));
const problems = [
...unknownRuns.map((u) => `unknown run ${u}`),
...doubleBooked.map((d) => `double-booked ${d}`),
...missing.map((m) => `not in database ${m}`),
];
const perRun = new Map<string, number>();
for (const row of seen.values()) {
if (known.has(row.wagonNumber)) {
perRun.set(row.exportRun, (perRun.get(row.exportRun) ?? 0) + 1);
}
}
console.log(`\nFile: ${path}`);
console.log(`Rows read: ${rows.length + unknownRuns.length} | assignable: ${known.size}`);
console.table(
Object.keys(TRAIN_RUN_PAIRS).map((exportRun) => ({
export_run: exportRun,
import_run: TRAIN_RUN_PAIRS[exportRun],
wagons: perRun.get(exportRun) ?? 0,
})),
);
if (problems.length) {
console.error(`\n${problems.length} problem(s) — nothing was written:`);
problems.forEach((p) => console.error(` ${p}`));
console.error('\nFix these in the source sheet, then re-run.');
process.exit(1);
}
if (!apply) {
console.log('\nDry run — no changes written. Re-run with --apply to write.');
return;
}
await AppDataSource.transaction(async (manager) => {
// Full replacement: clear first so a wagon dropped from the sheet does not
// keep a run it no longer has.
await manager.query(`
UPDATE freight.wagons
SET export_train_number = NULL, import_train_number = NULL
WHERE export_train_number IS NOT NULL;
`);
for (const exportRun of new Set([...seen.values()].map((r) => r.exportRun))) {
const wagons = [...seen.values()]
.filter((r) => r.exportRun === exportRun)
.map((r) => r.wagonNumber);
await manager.query(
`UPDATE freight.wagons
SET export_train_number = $1,
import_train_number = $2,
updated_at = now()
WHERE wagon_number = ANY($3::text[]);`,
[exportRun, TRAIN_RUN_PAIRS[exportRun], wagons],
);
}
});
const [totals] = await AppDataSource.query(`
SELECT COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run
FROM freight.wagons WHERE deleted_at IS NULL;
`);
console.log(`\nApplied. ${totals.on_a_run} wagons now on a run.`);
} finally {
await AppDataSource.destroy();
}
}
updateWagonRuns().catch((error) => {
console.error('Failed to update wagon runs:', error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -48,6 +48,14 @@ const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [
"Minimum days between today and the vessel departure date on an export Release Order.",
multiple: false,
},
{
code: "import_train_numbers",
label: "Import train numbers",
description:
"Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import 1).",
multiple: false,
meta: { searchable: true, clearable: true },
},
];
@Injectable()

View File

@@ -13,11 +13,13 @@ const INDODE_FACILITY = {
facilityType: 'DRY_PORT' as const,
facilityStatus: 'ACTIVE' as const,
locationName: 'Indode',
country: 'Djibouti',
city: 'Djibouti',
address: 'Indode, Djibouti',
latitude: 11.5447,
longitude: 43.145,
// Indode is the Gelan Multipurpose Port outside Addis — the yard carries it as
// KALITY, country Ethiopia. It was seeded as Djibouti, which is the wrong end
// of the line. Coordinates are left unset rather than guessed; fill them in
// when the real position is to hand.
country: 'Ethiopia',
city: 'Addis Ababa',
address: 'Indode (Gelan), Addis Ababa, Ethiopia',
capacity: 50000,
isActive: true,
notes: 'Primary dry port for container consolidation and distribution',
@@ -72,20 +74,22 @@ export class IndodeFacilitySeeder {
const yardRepo = manager.getRepository(WarehouseYard);
const zoneRepo = manager.getRepository(WarehouseZone);
// Ensure facility exists
const facility = await facilityRepo.findOne({
// Ensure facility exists. An existing facility row is not proof the
// warehouses under it survived, so reuse it and carry on rather than
// returning — otherwise a facility with no warehouses stays that way.
const existing = await facilityRepo.findOne({
where: { code: INDODE_FACILITY.code },
});
if (facility) {
this.logger.log('Indode facility already exists, skipping seed');
return;
let savedFacility: Facility;
if (existing) {
savedFacility = await facilityRepo.save({ ...existing, ...INDODE_FACILITY });
this.logger.log(`Facility ${savedFacility.code} already exists, reusing`);
} else {
savedFacility = await facilityRepo.save(facilityRepo.create(INDODE_FACILITY));
this.logger.log(`Created facility: ${savedFacility.code}`);
}
const newFacility = facilityRepo.create(INDODE_FACILITY);
const savedFacility = await facilityRepo.save(newFacility);
this.logger.log(`Created facility: ${savedFacility.code}`);
// Create warehouses for the facility
for (const warehouseData of WAREHOUSES) {
try {

View File

@@ -16,12 +16,19 @@ import { DataSource } from 'typeorm';
* Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
* settled which is the intercity facility.
*/
const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true },
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false },
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false },
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false },
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false },
const FACILITY_YARDS: Array<{
code: string;
facility: string;
hasWarehouse: boolean;
handlesContainer: boolean;
}> = [
// Containers need a reach stacker or gantry — only these three are equipped.
// Bulk needs far less, so every facility handles it.
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true, handlesContainer: true },
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false, handlesContainer: true },
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false, handlesContainer: true },
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false, handlesContainer: false },
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false, handlesContainer: false },
];
@Injectable()
@@ -35,7 +42,7 @@ export class YardFacilitiesSeeder {
* yards — a missing code is logged and skipped rather than invented.
*/
async run(): Promise<void> {
for (const { code, facility, hasWarehouse } of FACILITY_YARDS) {
for (const { code, facility, hasWarehouse, handlesContainer } of FACILITY_YARDS) {
const [yard]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
[code],
@@ -53,11 +60,15 @@ export class YardFacilitiesSeeder {
);
await this.dataSource.query(
`INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes)
VALUES ($1, $2, $3)
`INSERT INTO freight.yard_facilities
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
VALUES ($1, $2, $3, true, $4)
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`,
[yard.id, hasWarehouse, `${facility} load/unload facility`],
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
handles_container = EXCLUDED.handles_container,
handles_bulk = EXCLUDED.handles_bulk,
updated_at = NOW()`,
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
);
}
this.logger.log(

View File

@@ -846,6 +846,16 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="contracts/clearance-documents/:id"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<ContractClearanceDetailPage />
</RequirePermission>
}
/>
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"

View File

@@ -15,10 +15,23 @@ import {
} from "./cookies";
import type { AuthTokens } from "./types";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
*/
suppressErrorModal?: boolean;
}
}
type RetriableRequest = {
_retry?: boolean;
headers?: Record<string, string>;
url?: string;
suppressErrorModal?: boolean;
};
const api = axios.create({
@@ -100,8 +113,13 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them).
if (error.response && error.response.status !== 401) {
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
if (
error.response &&
error.response.status !== 401 &&
!originalRequest?.suppressErrorModal
) {
const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload);
}

View File

@@ -0,0 +1,43 @@
import { Link } from "react-router-dom";
/**
* The parent contract's reference, linking to that contract's detail page.
*
* Backoffice-local on purpose: the contract detail route differs per app
* (`/dashboard/contract-requests/:id` here vs `/contracts/:id` in the portal),
* so the portal keeps its own copy in `pages/bookings/booking-display.tsx`
* rather than the two sharing a component that would have to take the route as
* a prop at every call site.
*
* Renders nothing when either field is missing: `contractId` is nullable on the
* booking, and only the bookings list/detail endpoints join `contractReference`
* — other endpoints (warehouse, fleet, payments) return booking rows without it,
* and a link with no id would be a dead one.
*
* `stopPropagation` matters: booking rows are click-to-navigate, so without it a
* click here would race the row handler and land on the booking instead.
*/
export function ContractReferenceLink({
contractId,
contractReference,
className,
}: {
contractId?: string | null;
contractReference?: string | null;
className?: string;
}) {
if (!contractId || !contractReference) return null;
return (
<Link
to={`/dashboard/contract-requests/${contractId}`}
onClick={(e) => e.stopPropagation()}
className={
className ??
"block truncate font-mono text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
}
>
{contractReference}
</Link>
);
}

View File

@@ -24,6 +24,7 @@ import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
@@ -94,9 +95,15 @@ export function BookingRequestHero({
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<Stack gap={2} miw={0}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<ContractReferenceLink
contractId={booking.contractId}
contractReference={booking.contractReference}
/>
</Stack>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (

View File

@@ -4,6 +4,7 @@ import {
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -37,10 +38,12 @@ import {
FileDown,
FileText,
FileUp,
Flame,
MapPin,
Package,
Receipt,
Repeat,
Snowflake,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -128,6 +131,10 @@ interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: string;
/** Handling is per physical container; the line counts roll these up. */
isHazardous: boolean;
isReefer: boolean;
isReturn: boolean;
}
/** Mirrors the portal shipment form's container line: line-level quantity +
@@ -150,7 +157,14 @@ interface BulkDraft {
}
function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" };
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
}
function emptyLine(size: string): ContainerLineDraft {
@@ -285,6 +299,41 @@ export default function GlCreateBookingForm() {
// Legacy contracts (no equipment return chosen at creation) keep the old
// booking-level toggle.
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
/**
* Handling switches offered on each container row — only the services this
* contract was created with, since the server rejects the others.
*/
const handlingColumns = (
[
contract?.isHazardous && {
key: "isHazardous",
label: "Hazardous",
icon: <Flame size={14} />,
color: "#C0392B",
},
contract?.isReefer && {
key: "isReefer",
label: "Refrigerated",
icon: <Snowflake size={14} />,
color: "#2E5B96",
},
contractWithReturn && {
key: "isReturn",
label: "With return",
icon: <Repeat size={14} />,
color: "#0A6F4D",
},
] as Array<
| false
| undefined
| { key: keyof UnitDraft; label: string; icon: ReactNode; color: string }
>
).filter(Boolean) as Array<{
key: "isHazardous" | "isReefer" | "isReturn";
label: string;
icon: ReactNode;
color: string;
}>;
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
@@ -358,6 +407,9 @@ export default function GlCreateBookingForm() {
useEffect(() => {
if (!bookingRequest || prefilled) return;
// A rebook (?copyFrom=) seeds from the expired booking's real cargo —
// richer than the request's bare quantities. Let that seed win the race.
if (copyFromParam) return;
setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) {
@@ -399,13 +451,27 @@ export default function GlCreateBookingForm() {
setContainerLines(
lines.map((c) => {
const qty = Math.max(1, c.quantity);
// Carry the persisted per-unit details (numbers, seals, VGM, handling)
// when the source booking has them — a rebooked EXPIRED booking does,
// and its cargo is fixed server-side anyway.
const units: UnitDraft[] =
c.units?.length === qty
? c.units.map((u) => ({
containerNumber: u.containerNumber ?? "",
sealNumber: u.sealNumber ?? "",
vgmTons: u.vgmTons != null ? String(u.vgmTons) : "",
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
isReturn: Boolean(u.isReturn),
}))
: Array.from({ length: qty }, emptyUnit);
return {
containerSize: String(c.containerType?.sizeFt ?? ""),
quantity: String(qty),
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: Array.from({ length: qty }, emptyUnit),
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
returnQuantity: String(units.filter((u) => u.isReturn).length),
units,
};
}),
);
@@ -488,6 +554,18 @@ export default function GlCreateBookingForm() {
enabled: cargoQuery !== null && !isIntercity,
});
/**
* Line handling totals are a roll-up of the per-container switches — the
* count is however many containers ticked each service. Recomputed on every
* unit change so the price estimate and payload follow the switches.
*/
const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({
...line,
hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length),
reeferQuantity: String(line.units.filter((u) => u.isReefer).length),
returnQuantity: String(line.units.filter((u) => u.isReturn).length),
});
// Keep the units array length in sync with the entered quantity.
const syncUnits = (lineIdx: number, qty: number) => {
setContainerLines((prev) =>
@@ -496,7 +574,7 @@ export default function GlCreateBookingForm() {
const next = [...line.units];
while (next.length < qty) next.push(emptyUnit());
next.length = Math.max(0, qty);
return { ...line, units: next };
return withDerivedCounts({ ...line, units: next });
}),
);
};
@@ -511,11 +589,16 @@ export default function GlCreateBookingForm() {
unitIdx: number,
patch: Partial<UnitDraft>,
) =>
patchLine(lineIdx, {
units: containerLines[lineIdx].units.map((u, i) =>
i === unitIdx ? { ...u, ...patch } : u,
setContainerLines((prev) =>
prev.map((l, i) =>
i === lineIdx
? withDerivedCounts({
...l,
units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)),
})
: l,
),
});
);
// Same client-side validation as the customer portal shipment form
// (new-shipment-form/schema.ts): ISO container numbers unique within the
@@ -560,10 +643,15 @@ export default function GlCreateBookingForm() {
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: String(imported.filter((r) => r.withReturn).length),
// The spreadsheet marks handling per row — carry it onto the
// container it belongs to rather than collapsing it to a line count.
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: String(r.vgmTons),
isHazardous: Boolean(r.hazardous),
isReefer: Boolean(r.reefer),
isReturn: Boolean(r.withReturn),
})),
};
}),
@@ -742,6 +830,11 @@ export default function GlCreateBookingForm() {
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
// Per-container handling — the server rolls these into the line
// counts and bills each surcharge on the ticked containers only.
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}),
})),
}));
} else {
@@ -1175,78 +1268,60 @@ export default function GlCreateBookingForm() {
radius={10}
styles={fieldStyles}
/>
{contract.isHazardous && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Hazardous qty"
min={0}
value={line.hazardousQuantity}
error={
showErrors
? lineErrors[lineIdx]?.hazardousQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
hazardousQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isReefer && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Reefer qty"
min={0}
value={line.reeferQuantity}
error={
showErrors
? lineErrors[lineIdx]?.reeferQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
reeferQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contractWithReturn && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="With return qty"
description="Containers EDR returns empty"
min={0}
value={line.returnQuantity}
error={
showErrors
? lineErrors[lineIdx]?.returnQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
returnQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>
{handlingColumns.length > 0 ? (
<Text fz={11} c="dimmed" mt={4}>
Tick the services each individual container needs
charges apply only to the containers ticked
{handlingColumns
.map((col) => {
const count = line.units.filter(
(u) => u[col.key],
).length;
return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : "";
})
.join("")}
.
</Text>
) : null}
<Stack gap={10} mt={8}>
{/* Header row — input labels + handling-service labels,
one aligned grid shared by every unit row below.
Same layout as the portal shipment form. */}
{line.units.length > 0 && (
<Group gap={10} wrap="nowrap" align="flex-end">
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
</Text>
{handlingColumns.map((col) => (
<Group
key={col.key}
gap={4}
wrap="nowrap"
justify="center"
style={{ width: 96, flexShrink: 0 }}
>
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
))}
</Group>
)}
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
<Group key={unitIdx} gap={10} wrap="nowrap" align="flex-start">
<TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
@@ -1262,9 +1337,9 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
placeholder="Optional"
value={unit.sealNumber}
onChange={(e) =>
@@ -1274,11 +1349,11 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
step={0.01}
@@ -1295,7 +1370,32 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
{handlingColumns.map((col) => (
<Box
key={col.key}
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(unit[col.key])}
aria-label={`${col.label} — container ${unitIdx + 1}`}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
[col.key]: e.currentTarget.checked,
})
}
size="sm"
/>
</Box>
))}
</Group>
))}
</Stack>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
@@ -72,6 +72,7 @@ export type ClearanceViewLike = Pick<
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
| "riskHistory"
| "secondDuty"
| "importReleaseGranted"
> & { operationReady?: boolean };
@@ -1040,11 +1041,28 @@ function RiskStep({
done: boolean;
onChanged?: () => void;
}) {
const [level, setLevel] = useState<string>("GREEN");
const assigned = done || Boolean(clearance.riskLevel);
// Duty is advised off the risk level, so once that is done the decision is
// final. Until then a mis-assigned level must stay correctable — the server
// overwrites the milestone metadata on reassignment. Mirrors AssignRiskCard.
const locked = isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED");
const [level, setLevel] = useState<string>(clearance.riskLevel ?? "GREEN");
const [loading, setLoading] = useState(false);
if (done || clearance.riskLevel) {
return (
// The clearance view loads (and refetches after a reassignment) after first
// render, so mirror the persisted level onto the control whenever it changes —
// otherwise reopening the step offers GREEN whatever is actually assigned.
useEffect(() => {
if (clearance.riskLevel) setLevel(clearance.riskLevel);
}, [clearance.riskLevel]);
// Only the decisions before the current one — the badge above already states
// the level in force, so repeating it as a trail entry reads as a duplicate.
const priorDecisions = (clearance.riskHistory ?? []).slice(0, -1);
const assignedSummary = assigned ? (
<Stack gap={6}>
<Group gap="sm">
<Badge
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
@@ -1061,12 +1079,35 @@ function RiskStep({
. The customer can see this level.
</Text>
</Group>
);
{priorDecisions.length > 0 ? (
<Stack gap={2} pl="xs">
<Text size="xs" c="dimmed" fw={600}>
Previously
</Text>
{priorDecisions.map((entry, index) => (
<Text key={`${entry.assignedAt}-${index}`} size="xs" c="dimmed">
{entry.level}
{" · "}
{new Date(entry.assignedAt).toLocaleString()}
{entry.assignedBy ? ` · ${entry.assignedBy}` : ""}
{entry.note ? ` · ${entry.note}` : ""}
</Text>
))}
</Stack>
) : null}
</Stack>
) : null;
// Assigned and final: the badge is all that is left to show.
if (assigned && (locked || !canAct || !bookingId)) {
return assignedSummary;
}
// Customs cannot rate cargo still under transit — the server rejects the
// assignment until the T1 is closed, so do not offer the control yet.
if (!clearance.t1?.closed) {
// assignment until the T1 is closed, so do not offer the control yet. Skipped
// once a level exists: risk cannot have been assigned without a closed T1, so
// a still-open T1 here is stale data and must not hide the assigned badge.
if (!assigned && !clearance.t1?.closed) {
return (
<StepStatus
done={false}
@@ -1088,6 +1129,7 @@ function RiskStep({
return (
<Stack gap="sm">
{assignedSummary}
<SegmentedControl
fullWidth
value={level}
@@ -1100,19 +1142,24 @@ function RiskStep({
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
The customer sees the assigned risk level.
{assigned
? "Correctable until duty is advised. The customer sees the assigned risk level."
: "The customer sees the assigned risk level."}
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={loading}
disabled={assigned && level === clearance.riskLevel}
onClick={async () => {
setLoading(true);
try {
await contractsService.assignRisk(bookingId, {
riskLevel: level as Freight.CustomsRiskLevel,
});
toast.success("Customs risk assigned");
toast.success(
assigned ? "Customs risk reassigned" : "Customs risk assigned",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
@@ -1121,7 +1168,7 @@ function RiskStep({
}
}}
>
Assign risk
{assigned ? "Reassign risk" : "Assign risk"}
</Button>
</Group>
</Stack>

View File

@@ -104,6 +104,11 @@ export function ApiErrorModal() {
onClose={close}
centered
radius="md"
// Mounted at the app root, so its portal is FIRST in <body> — at the
// default z-index (200) any page modal opened later (create schedule,
// allocation wizard, …) paints over it and the error hides underneath.
// Hoist above every Mantine overlay and the react-hot-toast layer (9999).
zIndex={10000}
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />

View File

@@ -16,7 +16,8 @@ import { useEffect, useState } from "react";
import { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast";
import { IMPORT_TRAIN_OPTIONS, exportRunFor } from "@/constants/trainRuns";
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
import { exportRunFor } from "@/constants/trainRuns";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
@@ -42,6 +43,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
const [notes, setNotes] = useState("");
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
// Admin-managed run list (dropdown settings); numbers already on a train
// come back disabled so they cannot be picked twice.
const importNumbers = useImportTrainNumberOptions();
// Only serviceable locomotives standing in the selected yard can be coupled.
const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
@@ -150,12 +154,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
<Select
label="Import train number"
description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002"
data={IMPORT_TRAIN_OPTIONS}
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
data={importNumbers.options}
value={importTrainNumber || null}
onChange={(value) => setImportTrainNumber(value ?? "")}
searchable
clearable
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
/>
</Group>
<Select

View File

@@ -1,9 +1,11 @@
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { exportRunFor } from "@/constants/trainRuns";
import { useToast } from "@/hooks/use-toast";
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
import { api } from "@/services/api";
import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
@@ -15,9 +17,11 @@ export interface EditTrainDetailsModalProps {
/**
* Edit a built train's display identity from the list: its name and its fixed
* import/export run numbers. Composition (yard, locomotives, wagons) is edited
* on the detail page. Number collisions come back as a 409 with the owning
* train's code and surface verbatim.
* import/export run numbers. The import number comes from the admin-managed
* dropdown setting (numbers on other trains are disabled; this train's own
* number stays pickable) and the export number follows it. Composition (yard,
* locomotives, wagons) is edited on the detail page. Number collisions come
* back as a 409 with the owning train's code and surface verbatim.
*/
const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
const { toast } = useToast();
@@ -25,6 +29,8 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
const [importNo, setImportNo] = useState("");
const [exportNo, setExportNo] = useState("");
const importNumbers = useImportTrainNumberOptions(train?.importTrainNumber);
useEffect(() => {
if (train) {
setName(train.trainName ?? "");
@@ -86,20 +92,29 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
radius="md"
/>
<Group grow>
<TextInput
<Select
label="Import train no."
placeholder="e.g. 8002"
value={importNo}
onChange={(e) => setImportNo(e.currentTarget.value)}
maxLength={20}
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
data={importNumbers.options}
value={importNo || null}
onChange={(value) => {
// Clearing keeps the stored numbers (empty inputs are dropped on
// save); a pick re-derives the paired export run.
setImportNo(value ?? "");
setExportNo(value ? exportRunFor(value) : (train?.exportTrainNumber ?? ""));
}}
searchable
clearable
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
radius="md"
/>
<TextInput
label="Export train no."
description="Follows the import run"
placeholder="e.g. 8001"
value={exportNo}
onChange={(e) => setExportNo(e.currentTarget.value)}
maxLength={20}
readOnly
variant="filled"
radius="md"
/>
</Group>

View File

@@ -15,6 +15,8 @@ export const trainStatusColor = (status: BuiltTrainStatus | string): string => {
return "yellow";
case "OUT_OF_SERVICE":
return "red";
case "DEACTIVATED":
return "gray";
default:
return "gray";
}

View File

@@ -40,17 +40,21 @@ export function PreviewSummary({
summary?: {
totalBookings: number;
totalWeightTons: number;
grossWeightTons?: number;
totalTareTons?: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
}) {
if (!summary) return null;
// GROSS — the axis every train limit is spent against.
const gross = summary.grossWeightTons ?? summary.totalWeightTons;
const stats = [
{ label: "Bookings", value: String(summary.totalBookings) },
{ label: "Wagons", value: String(summary.wagonsNeeded) },
{ label: "Wagon type", value: summary.wagonType },
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
{ label: "Gross weight", value: `${gross}T` },
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (

View File

@@ -99,9 +99,8 @@ function usedWeight(schedule: TrainScheduleDetail): number {
/**
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
* unknown). The API caps at the weakest loco, not the sum of all locos — a
* consist can only pull as hard as its weakest engine. Note: the API also adds
* the consist tare to the used weight when it checks this cap; tare isn't
* available client-side, so this meter compares cargo-only load against pull.
* consist can only pull as hard as its weakest engine. Both sides of this meter
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
*/
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;

View File

@@ -46,6 +46,8 @@ type NormalizedWagon = {
const CAR_WIDTH = 150; // car body + coupler footprint
const round1 = (n: number) => Math.round(n * 10) / 10;
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
const allocations = w.allocations ?? [];
const firstLoad = (
@@ -268,10 +270,11 @@ const CONTAINER_BORDERS = [
];
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
// GROSS on both sides: cargo + tare vs rated payload + tare.
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
const utilization =
wagon.capacityTons > 0
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
: 0;
maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0;
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;
@@ -281,8 +284,8 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
}${
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
}`;
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
@@ -369,7 +372,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
/>
</Box>
<Text size="9px" c="dimmed" ta="center" fw={600}>
{wagon.assignedWeightTons}/{wagon.capacityTons}T
{grossTons}/{maxGrossTons}T
</Text>
</Stack>
) : (
@@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
</Text>
{!wagon.isEmpty ? (
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
{wagon.assignedWeightTons}T
{grossTons}T
</Text>
) : null}
</Group>

View File

@@ -6,6 +6,7 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
tareWeightTons?: number | null;
slotLoadType?: string;
wagonType?: { code: string } | null;
wagonTypeCode?: string;
@@ -21,6 +22,8 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
}>;
};
const round1 = (n: number) => Math.round(n * 10) / 10;
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
const normalized = loadType?.toUpperCase() ?? "";
if (normalized.includes("BULK")) return "orange";
@@ -68,8 +71,16 @@ export function WagonPlanGrid({
);
}
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
// GROSS on both sides: cargo + tare vs rated payload + tare.
const totalTare = round1(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const totalCapacity = round1(
wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0) + totalTare,
);
const totalAssigned = round1(
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0) + totalTare,
);
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
@@ -82,7 +93,7 @@ export function WagonPlanGrid({
</Text>
{isBulk ? (
<Text size="sm" c="dimmed">
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
Gross: <strong>{totalAssigned}</strong> / {totalCapacity}T
</Text>
) : null}
</Group>
@@ -90,8 +101,9 @@ export function WagonPlanGrid({
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
{wagonPlan.map((wagon) => {
const seq = wagon.sequenceNo;
const capacity = wagon.capacityTons;
const assigned = wagon.assignedWeightTons;
const tare = Number(wagon.tareWeightTons) || 0;
const capacity = round1(wagon.capacityTons + tare);
const assigned = round1(wagon.assignedWeightTons + tare);
const allocations = wagon.allocations ?? [];
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const label = slotLabel(wagon, freightType);
@@ -149,7 +161,7 @@ export function WagonPlanGrid({
</Text>
{label === "BULK" ? (
<Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T
{alloc.allocatedWeightTons}T cargo
</Text>
) : null}
</Group>

View File

@@ -132,7 +132,7 @@ export const BookingDetailModal = ({
/>
<InfoRow
icon={<Weight size={15} />}
label="Weight"
label="Gross weight"
value={
<Text size="sm" fw={700}>
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}

View File

@@ -161,8 +161,11 @@ function WagonCar({
const allocation = wagon.allocations?.[0];
const isEmpty = !allocation;
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0;
const capacity = wagon.capacityTons ?? 0;
// GROSS on both sides: cargo + tare vs rated payload + tare.
const tare = wagon.tareWeightTons ?? 0;
const assigned =
(allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare;
const capacity = (wagon.capacityTons ?? 0) + tare;
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;

View File

@@ -21,6 +21,8 @@ export const RemoveBookingModal = ({
if (!wagon || !wagon.allocations?.[0]) return null;
const allocation = wagon.allocations[0];
// GROSS: allocated cargo + the tare of the wagon it sits on.
const grossTons = (allocation.allocatedWeightTons ?? 0) + (wagon.tareWeightTons ?? 0);
return (
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
@@ -40,7 +42,7 @@ export const RemoveBookingModal = ({
</Badge>
</Text>
<Text size="sm">
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
</Text>
<Text size="sm">
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}

View File

@@ -93,10 +93,14 @@ export const TrainConsistView = ({
}
};
const weightUsed = wagons.reduce(
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0),
// GROSS: cargo on every allocation + the tare of every wagon in the consist.
// maxPullWeightTons is a gross limit, so the numerator must be gross too.
const cargoUsed = wagons.reduce(
(sum, w) => sum + (w.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
0,
);
const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0);
const weightUsed = cargoUsed + tareUsed;
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
return (

View File

@@ -98,7 +98,7 @@ export const TrainStatsBar = ({
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
<StatTile
icon={<Weight size={15} />}
label="Weight"
label="Gross weight"
pct={weightPct}
current={weightUsed.toFixed(1)}
max={weightMax?.toFixed(1) ?? "∞"}

View File

@@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
// GROSS (cargo + wagon tare) so this badge shares the axis every other
// weight on the page uses — cargo-only here read ~25% light.
const weight = Number(booking.grossWeightTons ?? booking.cargoTotalWeightVgm ?? 0);
const fits = booking.canAssign;
const blockReason = booking.blockReason;

View File

@@ -36,8 +36,12 @@ export const WagonCard = ({
const hasAllocations = Boolean(allocation);
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const weightUsed = allocation?.allocatedWeightTons ?? 0;
const weightMax = wagon.capacityTons ?? 0;
// GROSS on both sides: loaded cargo + wagon tare, against the wagon's max
// gross (rated payload + tare). Keeps the wagon axis identical to the train
// axis in TrainStatsBar.
const tare = wagon.tareWeightTons ?? 0;
const weightUsed = (allocation?.allocatedWeightTons ?? 0) + tare;
const weightMax = (wagon.capacityTons ?? 0) + tare;
const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
const wagonType = wagon.wagonType?.code || "UNKNOWN";

View File

@@ -54,10 +54,26 @@ export const TRAIN_RUN_FILTER_OPTIONS = Object.entries(TRAIN_RUN_PAIRS).map(
}),
);
/** The import run implied by an export run; empty string when unset/unknown. */
export const importRunFor = (exportRun: unknown): string =>
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? "";
/**
* The import run implied by an export run; empty string when unset/unknown.
* Runs outside the hardcoded pairs (admin-added via dropdown settings) fall
* back to the numeric convention: import = export + 1.
*/
export const importRunFor = (exportRun: unknown): string => {
const run = String(exportRun ?? "");
const paired = TRAIN_RUN_PAIRS[run];
if (paired) return paired;
return /^\d*[13579]$/.test(run) ? String(Number(run) + 1) : "";
};
/** The export run implied by an import run; empty string when unset/unknown. */
export const exportRunFor = (importRun: unknown): string =>
EXPORT_BY_IMPORT[String(importRun ?? "")] ?? "";
/**
* The export run implied by an import run; empty string when unset/unknown.
* Runs outside the hardcoded pairs (admin-added via dropdown settings) fall
* back to the numeric convention: export = import 1.
*/
export const exportRunFor = (importRun: unknown): string => {
const run = String(importRun ?? "");
const paired = EXPORT_BY_IMPORT[run];
if (paired) return paired;
return /^\d*[02468]$/.test(run) && Number(run) > 0 ? String(Number(run) - 1) : "";
};

View File

@@ -19,6 +19,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
id: booking.id,
reference: booking.reference,
contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
approvalSteps: booking.approvalSteps,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")

View File

@@ -0,0 +1,67 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { IMPORT_TRAIN_OPTIONS } from "@/constants/trainRuns";
import { api } from "@/services/api";
/** Dropdown-settings code holding the admin-managed IMPORT run numbers. */
export const IMPORT_TRAIN_NUMBERS_CODE = "import_train_numbers";
export interface ImportTrainNumberOption {
value: string;
label: string;
disabled?: boolean;
}
/**
* Selectable IMPORT run numbers for the Train Builder, sourced from the
* admin-managed `import_train_numbers` dropdown setting (admins add new runs
* from the Dropdown Settings editor). Falls back to the legacy hardcoded run
* list while the setting is missing or has no options.
*
* Numbers already claimed by an existing train are kept in the list but
* disabled and tagged "in use". Pass `currentNumber` when editing a train so
* its own number stays pickable, and so a legacy number that was removed from
* the setting still renders.
*/
export function useImportTrainNumberOptions(currentNumber?: string | null) {
const settingQuery = useQuery(
api.dropdownSettings.getByCode.queryOptions({
input: { code: IMPORT_TRAIN_NUMBERS_CODE },
staleTime: 5 * 60_000,
retry: false,
}),
);
const usedQuery = useQuery(
api.trainBuilder.usedTrainNumbers.queryOptions({ staleTime: 30_000 }),
);
const options = useMemo<ImportTrainNumberOption[]>(() => {
const configured = [...(settingQuery.data?.children ?? [])]
.filter((option) => !option.disabled)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((option) => ({
value: option.value,
label: option.label || option.value,
}));
const base = configured.length ? configured : IMPORT_TRAIN_OPTIONS;
const used = new Set(usedQuery.data?.importTrainNumbers ?? []);
if (currentNumber) used.delete(currentNumber);
const items: ImportTrainNumberOption[] = base.map((option) =>
used.has(option.value)
? { ...option, label: `${option.label} — in use`, disabled: true }
: option,
);
if (currentNumber && !items.some((option) => option.value === currentNumber)) {
items.unshift({ value: currentNumber, label: currentNumber });
}
return items;
}, [settingQuery.data, usedQuery.data, currentNumber]);
return {
options,
isLoading: settingQuery.isLoading || usedQuery.isLoading,
};
}

View File

@@ -27,7 +27,8 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
// staleTime: 30_000,
staleTime:0,
// Data freshness is driven by mutation invalidation (MutationCache above),
// socket pushes, and explicit polling — not by tab focus. Focus refetch
// just re-fires every mounted query each time the window is refocused.

View File

@@ -48,6 +48,7 @@ import {
useBookingList,
useBookingListSummary,
} from "@/hooks/bookings/useBookings";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
@@ -308,7 +309,17 @@ export default function BookingRequestsPage() {
return (
<div className="py-1">
{ref ? (
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
// Fall back to plain text when the id is missing — the reference is
// still worth showing, it just has nowhere to link to.
(row.original.contractId ? (
<ContractReferenceLink
contractId={row.original.contractId}
contractReference={ref}
className="truncate font-mono text-xs text-foreground underline underline-offset-2 hover:text-primary"
/>
) : (
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
))
) : (
<span className="text-xs text-muted-foreground"></span>
)}

View File

@@ -23,6 +23,7 @@ import {
Clock,
PackageCheck,
PackagePlus,
RotateCcw,
ShieldCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -110,6 +111,16 @@ export default function DocumentClearanceDetailPage() {
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
// The completed booking expired unpaid at train dispatch. Its per-booking
// clearance is finished, so GL rebooks it onto a new day — the customer never
// re-requests the shipment or pays the clearance fee again.
const canRebookExpired =
booking?.status === "EXPIRED" &&
Boolean(booking?.contractId) &&
Number(booking?.totalAmount ?? 0) > 0 &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
@@ -194,6 +205,19 @@ export default function DocumentClearanceDetailPage() {
>
Create booking
</Button>
) : canRebookExpired ? (
<Button
color="edr-green"
radius="md"
leftSection={<RotateCcw size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete?copyFrom=${id}`,
)
}
>
Rebook shipment
</Button>
) : undefined
}
/>

View File

@@ -452,7 +452,9 @@ export default function ClearanceDocumentsPage() {
data={contractRows}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/contracts/clearance/${row.id}`)
navigate(
`/dashboard/contracts/clearance-documents/${row.id}`,
)
}
pagination={{
pageIndex: pagination.pageIndex,

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useLocation, useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -52,9 +52,21 @@ import {
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { pathname } = useLocation();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
// The same detail page serves two hubs: the GL "Document Clearance" list and
// the Operations "Clearance Documents" list. Point back-navigation at
// whichever hub the user came through.
const fromOpsHub = pathname.startsWith(
"/dashboard/contracts/clearance-documents",
);
const hubHref = fromOpsHub
? "/dashboard/contracts/clearance-documents"
: "/dashboard/contracts/clearance";
const hubLabel = fromOpsHub ? "Clearance Documents" : "Document Clearance";
const { data: contract, refetch: refetchContract } = useContractDetail(id);
const {
data: clearance,
@@ -153,12 +165,9 @@ export default function ContractClearanceDetailPage() {
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/contracts/clearance"
backTo={hubHref}
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: hubLabel, href: hubHref },
{ label: "Not found" },
]}
/>
@@ -176,12 +185,9 @@ export default function ContractClearanceDetailPage() {
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
backTo={hubHref}
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: hubLabel, href: hubHref },
{ label: reference },
]}
meta={

View File

@@ -197,6 +197,29 @@ function DirectionIcon({ direction }: { direction: string }) {
}
function StatusBadge({ row }: { row: ClearanceRow }) {
// Terminal contracts stay listed as history — badge the terminal state
// instead of falling through to "Under review".
if (["EXPIRED", "CANCELLED", "REJECTED"].includes(row.status)) {
return (
<Tooltip
label="This contract is no longer active — kept here for clearance history."
withArrow
>
<Badge
size="sm"
variant="light"
color={row.status === "EXPIRED" ? "orange" : "red"}
radius="sm"
>
{row.status === "EXPIRED"
? "Contract expired"
: row.status === "CANCELLED"
? "Cancelled"
: "Rejected"}
</Badge>
</Tooltip>
);
}
if (row.paymentExpired) {
return (
<Tooltip
@@ -727,8 +750,12 @@ export default function ContractClearanceListPage() {
)
}
onRebook={(row) =>
// Re-complete the SAME expired booking (new day, same finished
// per-booking clearance) — a fresh create-booking would spawn a
// new instance and force the customer through clearance + fee
// again.
navigate(
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
)
}
onViewContract={(contractId) =>
@@ -813,6 +840,17 @@ const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
if (
[
"SELECTED_FOR_BATCH",
"PNR_GENERATED",
"AWAITING_PAYMENT",
"PAYMENT_VERIFICATION_IN_PROGRESS",
].includes(s)
)
return "violet";
if (s === "EXPIRED") return "orange";
if (s === "CANCELLED" || s === "REJECTED") return "red";
return "gray";
};

View File

@@ -4,11 +4,14 @@ import {
Button,
Card,
Group,
MultiSelect,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -17,6 +20,7 @@ import {
CheckCircle2,
Clock,
FileText,
FilterX,
Inbox,
LayoutList,
RefreshCw,
@@ -36,7 +40,10 @@ import {
} from "@/components/contracts/ContractStatusTabs";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config";
import {
CONTRACT_LIST_TABS,
CONTRACT_STATUS_STYLES,
} from "@/features/contracts/contract-status.config";
import {
getStaffRowAction,
toContractListRow,
@@ -61,6 +68,63 @@ function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
return match.statuses.join(",");
}
/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */
function getStatusOptionsForTab(
tab: ContractStatusTabKey,
): { value: string; label: string }[] {
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
const statuses = match?.statuses?.length
? match.statuses
: CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []);
return statuses.map((s) => ({
value: s,
label: CONTRACT_STATUS_STYLES[s]?.label ?? s,
}));
}
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
const CONTRACT_KIND_OPTIONS = [
{ value: "GENERAL", label: "General (recurring)" },
{ value: "ONE_TIME", label: "One-time" },
];
const CURRENCY_OPTIONS = [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
];
/** value = `${sortBy}:${sortOrder}` for the sort Select. */
const SORT_OPTIONS = [
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
{ value: "contractValidUntil:ASC", label: "Expiring soonest" },
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
];
/** Local start-of-day → ISO, for inclusive "from" date filters. */
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
/** Local end-of-day → ISO, for inclusive "to" date filters. */
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
@@ -79,32 +143,86 @@ export default function ContractRequestsPage() {
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
null,
);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [currencyFilter, setCurrencyFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [sort, setSort] = useState<string>("createdAt:DESC");
const tabStatuses = getStatusesForTab(activeTab);
const statusOptions = useMemo(
() => getStatusOptionsForTab(activeTab),
[activeTab],
);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
const filter: ContractListFilter = useMemo(
() => ({
const filter: ContractListFilter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
sortBy,
sortOrder,
tab: activeTab,
// Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
debouncedQuery,
],
);
// Explicit status picks narrow within the tab; otherwise the tab's
// status group applies.
...(statusFilter.length
? { statuses: statusFilter.join(",") }
: tabStatuses
? { statuses: tabStatuses }
: {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(kindFilter ? { contractKind: kindFilter } : {}),
...(currencyFilter ? { paymentCurrency: currencyFilter } : {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
};
}, [
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
debouncedQuery,
statusFilter,
directionFilter,
freightTypeFilter,
kindFilter,
currencyFilter,
createdFrom,
createdTo,
sort,
]);
const activeFilterCount =
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(kindFilter ? 1 : 0) +
(currencyFilter ? 1 : 0) +
(createdFrom || createdTo ? 1 : 0);
const clearFilters = useCallback(() => {
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
setKindFilter(null);
setCurrencyFilter(null);
setCreatedFrom(null);
setCreatedTo(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter);
@@ -341,6 +459,8 @@ export default function ContractRequestsPage() {
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
// Status picks belong to the previous tab's option set — reset.
setStatusFilter([]);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={tabCounts}
@@ -349,38 +469,159 @@ export default function ContractRequestsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Stack gap="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={SORT_OPTIONS}
value={sort}
onChange={(v) => {
setSort(v ?? "createdAt:DESC");
resetPage();
}}
allowDeselect={false}
radius="lg"
style={{ minWidth: 170 }}
aria-label="Sort contracts"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<MultiSelect
placeholder={
statusFilter.length ? undefined : "All statuses"
}
data={statusOptions}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
aria-label="Filter by status"
/>
<Select
placeholder="All directions"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 150 }}
aria-label="Filter by trade direction"
/>
<Select
placeholder="All freight types"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="All kinds"
data={CONTRACT_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by contract kind"
/>
<Select
placeholder="All currencies"
data={CURRENCY_OPTIONS}
value={currencyFilter}
onChange={(v) => {
setCurrencyFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by payment currency"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
</Stack>
</Box>
{showEmpty ? (

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -18,7 +18,6 @@ import {
AlertCircle,
ClipboardList,
FileText,
PackagePlus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -61,9 +60,12 @@ type GlClearanceDetail =
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
try {
// Probe the contract endpoints first; a booking-id row 404s here by design
// and falls back to the booking lookup below. Suppress the global error
// modal so that expected 404 never surfaces to the user.
const [clearance, contract] = await Promise.all([
contractsService.getClearance(id),
contractsService.getById(id),
contractsService.getClearance(id, { suppressErrorModal: true }),
contractsService.getById(id, { suppressErrorModal: true }),
]);
return {
kind: "contract",
@@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
export default function GlClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
@@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() {
{hasRo ? "Replace RO" : "Upload RO"}
</Button>
)}
{canCompleteBooking && shipmentBooking ? (
<Button
color="edr-green"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
)
}
>
Create booking
</Button>
) : null}
</Group>
}
/>

View File

@@ -106,6 +106,18 @@ function statusColor(status: string): string {
case "ACTIVE_SHIPMENT_IN_PROGRESS":
case "IN_TRANSIT":
return "teal";
// Payment phase — booking selected / awaiting the customer's payment.
case "SELECTED_FOR_BATCH":
case "PNR_GENERATED":
case "AWAITING_PAYMENT":
case "PAYMENT_VERIFICATION_IN_PROGRESS":
return "violet";
// Terminal rows kept as clearance history.
case "EXPIRED":
return "orange";
case "CANCELLED":
case "REJECTED":
return "red";
default:
return "gray";
}

View File

@@ -18,6 +18,8 @@ import {
CalendarClock,
MapPin,
MoreHorizontal,
Power,
PowerOff,
Replace,
Ruler,
Trash2,
@@ -70,6 +72,7 @@ export default function TrainBuilderDetailPage() {
const [locoModalOpen, setLocoModalOpen] = useState(false);
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
@@ -81,6 +84,8 @@ export default function TrainBuilderDetailPage() {
);
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
const busy =
@@ -172,6 +177,27 @@ export default function TrainBuilderDetailPage() {
>
Change yard
</Menu.Item>
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} reactivated` });
}, "Could not reactivate train")
}
>
Reactivate train
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)}
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
@@ -356,6 +382,39 @@ export default function TrainBuilderDetailPage() {
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={deactivateOpen}
onClose={() => setDeactivateOpen(false)}
title={<Text fw={600}>Deactivate train {composition.code}?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
The train is parked and cannot be picked for new schedules until it is
reactivated. Its locomotives and wagons stay coupled.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeactivateOpen(false)}>
Keep active
</Button>
<Button
color="gray"
loading={deactivate.isPending}
onClick={() =>
void withToast(async () => {
await deactivate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} deactivated` });
setDeactivateOpen(false);
}, "Could not deactivate train")
}
>
Deactivate
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={disbandOpen}
onClose={() => setDisbandOpen(false)}

View File

@@ -315,6 +315,7 @@ export default function TrainBuilderListPage() {
{ value: "IN_SERVICE", label: "In service" },
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
{ value: "OUT_OF_SERVICE", label: "Out of service" },
{ value: "DEACTIVATED", label: "Deactivated" },
]}
w={180}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}

View File

@@ -66,8 +66,8 @@ import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchBoardCounts,
BatchBoardScheduleDetail,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
@@ -398,7 +398,7 @@ const BookingTable = memo(function BookingTable({
);
});
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
function WindowCountChips({ counts }: { counts: BatchBoardCounts }) {
const chips: Array<{ value: number; color: string; label: string }> = [
{ value: counts.allocated, color: "edr-green", label: "allocated" },
{ value: counts.selectedForBatch, color: "orange", label: "selected" },
@@ -609,9 +609,7 @@ export default function BatchScheduleDetailPage() {
const hasAssignedWagons = useMemo(
() =>
Boolean(
data?.windows.some((w) =>
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
) ||
data?.bookings.some((b) => b.allocationStatus === "ASSIGNED") ||
data?.pendingContract.bookings.some(
(b) => b.allocationStatus === "ASSIGNED",
),
@@ -619,36 +617,33 @@ export default function BatchScheduleDetailPage() {
[data],
);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const scheduleDetailQuery = useQuery(
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
enabled: Boolean(scheduleId),
// The heavy composition graph is only rendered by the composition tab and
// the overview diagram (which needs assigned wagons) — don't fetch it
// until one of them can actually show something.
enabled:
Boolean(scheduleId) &&
(hasAssignedWagons || activeTab === "composition"),
// Composition data only changes through mutations, which invalidate the
// whole train-scheduling root — no need to refetch on remounts in between.
staleTime: 5 * 60_000,
}),
);
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
// Every booking on this schedule: in-window + pending-contract (the two
// buckets are disjoint). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map<string, BatchBoardBookingDetail>();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
return [...data.bookings, ...data.pendingContract.bookings];
}, [data]);
// All bookings that fall inside the schedule's booking window (every window
// cycle, flattened) — the window is one booking day, so these belong to the
// single window panel above.
const windowBookings = useMemo(
() => (data?.windows ?? []).flatMap((w) => w.bookings),
[data?.windows],
);
// Bookings inside the schedule's booking window — they belong to the single
// window panel above.
const windowBookings = data?.bookings ?? [];
const windowCounts = useMemo(() => {
const counts = {
@@ -684,7 +679,6 @@ export default function BatchScheduleDetailPage() {
[data?.status],
);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
null,

View File

@@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) =>
const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT";
const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED";
/** Yards with no equipment can never load/unload — surface it before the train arrives. */
function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) {
/**
* A yard that can't handle THIS booking's cargo can never work it — surface that
* while the train is still coming, not when the load is refused. Containers need
* a facility with a stacker (Indode, Modjo, Dire Dawa); bulk is handled at all of
* them.
*/
function FacilityCell({
yard,
has,
freightType,
}: {
yard: string | null;
has: boolean | null;
freightType: string | null;
}) {
if (!yard) return <Text size="sm"></Text>;
if (has) return <Text size="sm">{yard}</Text>;
return (
<Tooltip
label="This yard has no load/unload facility — cargo cannot be handled here"
label={`${yard} cannot handle ${(freightType ?? "this").toLowerCase()} cargo — no facility here, or no equipment for it`}
withArrow
multiline
w={240}
w={260}
>
<Group gap={4} wrap="nowrap">
<AlertTriangle size={13} color="var(--mantine-color-red-6)" />
@@ -95,7 +108,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Td>{r.customer ?? "—"}</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FacilityCell yard={r.origin} has={r.originHasFacility} />
<FacilityCell yard={r.origin} has={r.originHasFacility} freightType={r.freightType} />
{atOrigin(r) && isWaiting(r) && (
<Badge size="xs" color="edr-green" variant="light">
train here
@@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FacilityCell yard={r.destination} has={r.destinationHasFacility} />
<FacilityCell yard={r.destination} has={r.destinationHasFacility} freightType={r.freightType} />
{atDestination(r) && isRiding(r) && (
<Badge size="xs" color="edr-green" variant="light">
train here
@@ -215,7 +228,7 @@ export default function IntercityPage() {
<Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} />
<Stat
icon={<AlertTriangle size={18} />}
label="No facility"
label="Cannot handle"
value={blocked.length}
color={blocked.length > 0 ? "red" : undefined}
/>
@@ -229,8 +242,9 @@ export default function IntercityPage() {
title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`}
mb="md"
>
Their origin or destination yard has no load/unload facility. Mark the yard as
a facility in Configuration Yards, or the cargo can never be worked there.
Their origin or destination yard cannot handle that cargo no facility, or no
equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at
any facility. Adjust the yard in Configuration Yards.
</Alert>
)}

View File

@@ -193,6 +193,7 @@ import {
type ScheduleConsist,
type TrainComposition,
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
@@ -1825,6 +1826,14 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
),
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(
"train-builder",
"usedTrainNumbers",
() => trainBuilderService.usedTrainNumbers().then((r) => r.data),
),
build: endpoint<BuildTrainPayload, TrainComposition>(
"train-builder",
"build",
@@ -1902,6 +1911,22 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
deactivate: endpoint<string, TrainComposition>(
"train-builder",
"deactivate",
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
activate: endpoint<string, TrainComposition>(
"train-builder",
"activate",
(id) => trainBuilderService.activate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
disband: endpoint<string, void>(
"train-builder",
"disband",

View File

@@ -16,6 +16,9 @@ export interface ContractListFilter {
tradeDirection?: string;
contractKind?: string;
paymentCurrency?: string;
/** Created-at range (ISO strings, inclusive). */
createdFrom?: string;
createdTo?: string;
/** Server-side free-text search (contract reference, company name). */
search?: string;
page?: number;
@@ -139,6 +142,8 @@ function buildListParams(filter?: ContractListFilter) {
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.contractKind) params.contractKind = filter.contractKind;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
}
return params;
}
@@ -164,8 +169,11 @@ export const contractsService = {
};
},
getById: async (id: string): Promise<Freight.IContract> => {
const response = await client.get<Freight.IContract>(C.BY_ID(id));
getById: async (
id: string,
opts?: { suppressErrorModal?: boolean },
): Promise<Freight.IContract> => {
const response = await client.get<Freight.IContract>(C.BY_ID(id), opts);
return unwrap(response.data) as Freight.IContract;
},
@@ -258,8 +266,11 @@ export const contractsService = {
};
},
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
const response = await client.get(C.CLEARANCE(id));
getClearance: async (
id: string,
opts?: { suppressErrorModal?: boolean },
): Promise<Freight.ContractClearanceView> => {
const response = await client.get(C.CLEARANCE(id), opts);
return unwrap(response.data) as Freight.ContractClearanceView;
},

View File

@@ -9,7 +9,8 @@ export type BuiltTrainStatus =
| "SCHEDULED"
| "IN_SERVICE"
| "UNDER_MAINTENANCE"
| "OUT_OF_SERVICE";
| "OUT_OF_SERVICE"
| "DEACTIVATED";
export interface YardRefLite {
id: string;
@@ -140,6 +141,12 @@ export interface BuildTrainPayload {
notes?: string;
}
/** Run numbers already claimed by existing (non-deleted) trains. */
export interface UsedTrainNumbers {
importTrainNumbers: string[];
exportTrainNumbers: string[];
}
/** Edit a built train's display identity; omitted fields keep their value. */
export interface UpdateTrainDetailsPayload {
/** Empty string clears the name. */
@@ -261,6 +268,8 @@ export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
/** Import/export run numbers already claimed by existing trains. */
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
@@ -279,6 +288,11 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */
deactivate: (id: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/deactivate`),
/** Bring a DEACTIVATED train back to AVAILABLE. */
activate: (id: string) => apiClient.post<TrainComposition>(`${BASE}/${id}/activate`),
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
/** Built trains schedulable on a route (train-scheduling picker). */
availableTrains: (routeId: string) =>

View File

@@ -79,6 +79,8 @@ export interface BookingContainerUnit {
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
/** This container ships back empty after unloading (equipment return). */
isReturn?: boolean;
sortOrder?: number;
}
@@ -231,6 +233,8 @@ export interface BookingListRow {
id: string;
reference: string;
contractReference?: string | null;
/** Needed to link the reference to the contract's detail page. */
contractId?: string | null;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;

View File

@@ -134,7 +134,11 @@ export interface TrainSchedulePreviewResponse {
deferredBookings?: DeferredBookingRow[];
summary: {
totalBookings: number;
/** Cargo VGM only — display gross instead. */
totalWeightTons: number;
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons: number;
totalTareTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
@@ -396,23 +400,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
consolidationPartnerRef: string | null;
}
export interface BatchWindowGroup {
key: string;
label: string;
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
date: string;
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
dateLabel: string;
start: string;
end: string;
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
export interface BatchBoardCounts {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
}
/** A booking bucket on the detail board (in-window vs pending-contract). */
export interface BatchBoardBucket {
counts: BatchBoardCounts;
bookings: BatchBoardBookingDetail[];
}
@@ -439,8 +438,9 @@ export interface BatchBoardScheduleDetail {
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
/** Bookings inside the schedule's booking window (fully-executed contracts). */
bookings: BatchBoardBookingDetail[];
pendingContract: BatchBoardBucket;
allocationViolations: string[];
}
@@ -845,6 +845,8 @@ export interface CompositionUnassignedBooking {
freightType: FreightType | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: SchedulingStatus | null;
wagonsRequired: number;

View File

@@ -10,6 +10,7 @@ import {
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
import { ContractReferenceLink } from "@/pages/bookings/booking-display";
interface BookingRowProps {
booking: any;
@@ -27,9 +28,15 @@ export const BookingRow = memo(function BookingRow({
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page.
// instead of navigating to the detail page. A general contract is payable as
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
const payableStatus =
booking.bookingType === "GENERAL_CONTRACT"
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
booking.status === payableStatus && booking.paymentStatus !== "PAID";
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);
@@ -73,6 +80,7 @@ export const BookingRow = memo(function BookingRow({
<Text fz={15} fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<ContractReferenceLink booking={booking} />
<Text fz={12} c="edr-muted" truncate>
{commodity} · {origin} {dest}
</Text>

View File

@@ -13,7 +13,6 @@ import type { Freight } from "@edr/types";
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
import { CardTitle, SectionCard } from "./layout";
@@ -65,8 +64,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
return (
<SectionCard>
<BookingClearanceWorkflowBanner booking={booking} />
<Group justify="space-between" align="center" mb="md" mt="md">
{/* The "Clearance progress" stepper moved into the unified journey
wizard at the top of the page — this card keeps only the actions. */}
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance documents</CardTitle>
{action && (
<Button

Some files were not shown because too many files have changed in this diff Show More