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.demoUsersSeeder.run();
// await this.freightStaffUsersSeeder.run(); // await this.freightStaffUsersSeeder.run();
// await this.pricingDataSeeder.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.indodeFacilitySeeder.run();
// await this.batch14TestDataSeeder.run(); // await this.batch14TestDataSeeder.run();
// await this.batch5TestDataSeeder.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, vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm, totalVgmTons: qty * vgm,
isReefer: ct.isReefer, 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), perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty, quantity: qty,

View File

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

View File

@@ -42,6 +42,7 @@ export class BookingTransitionService {
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService, private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService, private readonly pricingService: BookingPricingService,
@Inject(forwardRef(() => BookingContractService))
private readonly contractService: BookingContractService, private readonly contractService: BookingContractService,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly fileUploadSettingsService: FileUploadSettingsService,
@@ -1049,7 +1050,25 @@ export class BookingTransitionService {
booking.tradeDirection === "EXPORT" && booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType); !isRoadService(booking.serviceType);
if (isExportTrain) { 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, { await this.bookingsRepository.update(bookingId, {

View File

@@ -1287,6 +1287,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany(); .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. */ /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> { findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository return this.repository
@@ -1342,6 +1359,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
if (!bookingIds.length) return Promise.resolve([]); if (!bookingIds.length) return Promise.resolve([]);
return this.bookingRepo(manager).find({ return this.bookingRepo(manager).find({
where: { id: In(bookingIds) }, 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: { relations: {
company: true, company: true,
originYard: true, originYard: true,

View File

@@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity {
@Column({ name: 'is_reefer', type: 'boolean', default: false }) @Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean; 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 }) @Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number; sortOrder!: number;

View File

@@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service'; import { BookingsService } from '../bookings/bookings.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.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 { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service'; 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). */ /** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null; riskLevel?: string | null;
riskAssignedAt?: 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). */ /** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null; secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean; importReleaseGranted?: boolean;
@@ -282,6 +287,12 @@ export class BookingClearanceService {
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString() ? riskMilestone.triggeredAt.toISOString()
: null, : 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, secondDuty,
importReleaseGranted: importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',

View File

@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
expect(saved.status).toBe('COMPLETED'); expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('YELLOW'); 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 * 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 * closed (accepted by GL Ethiopia after the train arrives) first, which is the
* catalog order T1_CLOSED → RISK_ASSIGNED. * 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( async assignRisk(
bookingId: string, bookingId: string,
riskLevel: CustomsRiskLevel, riskLevel: CustomsRiskLevel,
userId?: string, userId?: string,
note?: string, note?: string,
actor?: string,
): Promise<ClearanceMilestone> { ): Promise<ClearanceMilestone> {
await this.assertT1Closed(bookingId); 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. */ /** 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 { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.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 { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.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 { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.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. */ /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
@@ -54,6 +58,18 @@ export interface CreateBookingUnderContractResult {
warnings: string[]; 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. * The single create path for shipment bookings under a contract.
* *
@@ -270,8 +286,8 @@ export class ContractBookingService {
tradeDirection: contract.tradeDirection, tradeDirection: contract.tradeDirection,
freightType, freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto), cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous, isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: contract.isReefer, isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto), cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null,
@@ -580,11 +596,40 @@ export class ContractBookingService {
if (!booking || booking.contractId !== contract.id) { if (!booking || booking.contractId !== contract.id) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`); 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( throw new BadRequestException(
'Clearance must be finalized before the booking can be completed.', '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 // Path B: only GL Ethiopia completes a customs instance — the customer
// never enters shipment data on a customs contract. // never enters shipment data on a customs contract.
if (contract.customsClearingEnabled) { if (contract.customsClearingEnabled) {
@@ -966,9 +1011,11 @@ export class ContractBookingService {
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
* contract has no live split booking. * contract has no live split booking.
*/ */
private async splitOutstanding( /**
contract: Contract, * Public: the remainder-placement engine reads this to size the auto-created
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> { * remainder booking. Returns `null` when there is no live split chain.
*/
async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
const first = await this.dataSource const first = await this.dataSource
.getRepository(Booking) .getRepository(Booking)
.createQueryBuilder('b') .createQueryBuilder('b')
@@ -1015,6 +1062,25 @@ export class ContractBookingService {
const probe = await this.buildExportProbe(contract, route, dto, yards); const probe = await this.buildExportProbe(contract, route, dto, yards);
const report = await this.bookingBatchService.exportSpaceReport(probe); const report = await this.bookingBatchService.exportSpaceReport(probe);
if (report.scheduleId) return; 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( throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.', 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 * Resolve the booking's equipment return from the per-line return quantities
* (container freight). The CONTRACT gates the service — like hazardous: * (container freight). The CONTRACT gates the service — like hazardous:
@@ -1436,7 +1549,7 @@ export class ContractBookingService {
const lines = dto.containers ?? []; const lines = dto.containers ?? [];
for (const line of lines) { for (const line of lines) {
const qty = Number(line.returnQuantity ?? 0); const qty = this.handlingCounts(line).returnQuantity;
if (qty === 0) continue; if (qty === 0) continue;
if (contract.equipmentReturn !== 'WITH_RETURN') { if (contract.equipmentReturn !== 'WITH_RETURN') {
throw new BadRequestException( throw new BadRequestException(
@@ -1452,7 +1565,7 @@ export class ContractBookingService {
} }
if (contract.equipmentReturn === 'WITH_RETURN') { 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 anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
} }
return legacy; return legacy;
@@ -1490,9 +1603,10 @@ export class ContractBookingService {
); );
} }
const counts = this.handlingCounts(line);
const containerType = await this.resolveContainerTypeForSize( const containerType = await this.resolveContainerTypeForSize(
line.containerSize, line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0, contract.isReefer || counts.reeferQuantity > 0,
); );
const vgmPerUnit = line.units.length const vgmPerUnit = line.units.length
@@ -1506,12 +1620,10 @@ export class ContractBookingService {
containerTypeId: containerType.id, containerTypeId: containerType.id,
containerSize: line.containerSize, containerSize: line.containerSize,
quantity: line.quantity, quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0, hazardousQuantity: counts.hazardousQuantity,
reeferQuantity: line.reeferQuantity ?? 0, reeferQuantity: counts.reeferQuantity,
returnQuantity: returnQuantity:
contract.equipmentReturn === 'WITH_RETURN' contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
? (line.returnQuantity ?? 0)
: 0,
vgmPerUnitTons: vgmPerUnit, vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm, totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
@@ -1530,6 +1642,8 @@ export class ContractBookingService {
vgmTons: unit.vgmTons, vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false, isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false, isReefer: unit.isReefer ?? false,
isReturn:
contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
sortOrder: sortOrder++, sortOrder: sortOrder++,
}), }),
); );
@@ -1630,12 +1744,14 @@ export class ContractBookingService {
paymentCurrency: contract.paymentCurrency, paymentCurrency: contract.paymentCurrency,
serviceTypeId: contract.serviceTypeId, serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto), cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous, isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: contract.isReefer, isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment, isGovernment: contract.isGovernment,
shippingLineId: null, shippingLineId: null,
contractRouteId: route?.id ?? null, contractRouteId: route?.id ?? null,
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto), cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
@@ -1644,11 +1760,11 @@ export class ContractBookingService {
containerTypeId: ct.id, containerTypeId: ct.id,
containerSize: line.containerSize, containerSize: line.containerSize,
quantity: line.quantity, quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0, hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
reeferQuantity: line.reeferQuantity ?? 0, reeferQuantity: this.handlingCounts(line).reeferQuantity,
returnQuantity: returnQuantity:
contract.equipmentReturn === 'WITH_RETURN' contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0) ? this.handlingCounts(line).returnQuantity
: 0, : 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons, totalVgmTons,

View File

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

View File

@@ -31,6 +31,7 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards'; import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { import {
@@ -968,13 +969,16 @@ export class ContractsController {
assignRisk( assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto, @Body() dto: AssignRiskDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser,
) { ) {
return this.milestoneService.assignRisk( return this.milestoneService.assignRisk(
bookingId, bookingId,
dto.riskLevel, dto.riskLevel,
resolveAuthUserId(user), resolveAuthUserId(user),
dto.note, 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() @IsBoolean()
@Transform(({ value }) => value === 'true' || value === true) @Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean; 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 { 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 const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number]; 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): * 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` * - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
* Stored on the milestone so the timeline can render the value inline. * Stored on the milestone so the timeline can render the value inline.
*/ */
export interface MilestoneMetadata { export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel; riskLevel?: CustomsRiskLevel;
/**
* Append-only, oldest first. `riskLevel` is the current value and always
* equals the last entry's `level`.
*/
riskHistory?: RiskAssignmentRecord[];
dutyAmount?: number; dutyAmount?: number;
dutyCurrency?: string; dutyCurrency?: string;
declarationSerial?: string; declarationSerial?: string;

View File

@@ -256,6 +256,11 @@ export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
'FULLY_EXECUTED', 'FULLY_EXECUTED',
'CONTRACT_ACTIVE', 'CONTRACT_ACTIVE',
'CONTRACT_CLOSED', '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; ] as const;
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ /** 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_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED', 'OPERATION_CHANGES_REQUESTED',
'ROAD_DISPATCH_PENDING', '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', 'IN_TRANSIT',
'ARRIVED', 'ARRIVED',
'PAID', 'PAID',
'COMPLETED', 'COMPLETED',
'CONTRACT_ACTIVE', 'CONTRACT_ACTIVE',
'CONTRACT_CLOSED', '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; ] as const;
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ /** 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)) @Transform(({ value }) => Number(value))
rateValue!: number; 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]) @IsIn([...RATE_UNITS])
rateUnit!: string; rateUnit?: string;
} }
export class SubmitRateForApprovalDto { 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'; return 'HAZARD_SURCHARGE';
case 'REEFER': case 'REEFER':
return 'REEFER_SURCHARGE'; 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': case 'OVERWEIGHT':
return 'OVERWEIGHT_PER_TON'; return 'OVERWEIGHT_PER_TON';
case 'SHIPPING_LINE': case 'SHIPPING_LINE':

View File

@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
@Column({ name: 'has_warehouse', type: 'boolean', default: false }) @Column({ name: 'has_warehouse', type: 'boolean', default: false })
hasWarehouse!: boolean; 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 }) @Column({ name: 'equipment_notes', type: 'text', nullable: true })
equipmentNotes?: string | null; equipmentNotes?: string | null;

View File

@@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm';
@Index(['country']) @Index(['country'])
@Index(['isActive']) @Index(['isActive'])
export class Yard extends BaseEntity { 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; code!: string;
@Column({ name: 'label', type: 'varchar', length: 100 }) @Column({ name: 'label', type: 'varchar', length: 100 })

View File

@@ -42,6 +42,14 @@ export interface BookingContainerEvalInput {
isReefer?: boolean; isReefer?: boolean;
isOverweight?: boolean; isOverweight?: boolean;
overweightExcessTons?: number | null; 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 { export interface BookingEvaluationInput {
@@ -270,6 +278,27 @@ export class RuleEngineService {
(sum, r) => sum + (r.overweightExcessTons ?? 0), (sum, r) => sum + (r.overweightExcessTons ?? 0),
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 triggerValue: number | null = null;
let calculatedAmount: number; let calculatedAmount: number;
@@ -285,7 +314,11 @@ export class RuleEngineService {
calculatedAmount = triggerValue * rateValue; calculatedAmount = triggerValue * rateValue;
break; break;
case 'PER_CONTAINER': 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; calculatedAmount = triggerValue * rateValue;
break; break;
case 'PER_WAGON': case 'PER_WAGON':

View File

@@ -68,15 +68,21 @@ export class RatesService {
private resolveRateUnit( private resolveRateUnit(
appliesTo: Rate['appliesTo'], appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'], trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'], requestedUnit: Rate['rateUnit'] | undefined,
): Rate['rateUnit'] { ): 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 (trigger === 'OVERWEIGHT') return 'PER_TON';
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { const allowed = allowedRateUnits({ appliesTo, trigger });
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); if (!requestedUnit) {
throw new BadRequestException( 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; return requestedUnit;
@@ -272,7 +278,11 @@ export class RatesService {
tradeDirection, tradeDirection,
isBulk: this.resolvesToBulk(appliesTo, intercityKind), 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({ await this.assertNoDuplicatePattern({
rateType, rateType,

View File

@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
hasFacility: boolean; hasFacility: boolean;
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */ /** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
hasWarehouse: boolean; 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 * 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 * `yard_facilities` record says what it can actually do — whether it stores cargo
* warehouse move cargo on and off the train and nothing more — no storage, no * (storage/demurrage), and which freight types its equipment can lift. Containers
* demurrage. This is the single resolver the journey and handling flows use, so * need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
* they can't drift on what a facility is. * 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() @Injectable()
export class YardFacilitiesService { export class YardFacilitiesService {
constructor(private readonly dataSource: DataSource) {} constructor(private readonly dataSource: DataSource) {}
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */ private readonly SELECT = `
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> { SELECT y.id AS "yardId",
const [row]: Array<{ y.code AS "yardCode",
yardId: string; y.label AS "yardLabel",
yardCode: string | null; y.has_facility AS "hasFacility",
yardLabel: string | null; f.has_warehouse AS "hasWarehouse",
hasFacility: boolean; f.handles_container AS "handlesContainer",
hasWarehouse: boolean | null; f.handles_bulk AS "handlesBulk"
}> = await this.dataSource.query( FROM freight.yards y
`SELECT y.id AS "yardId", LEFT JOIN freight.yard_facilities f
y.code AS "yardCode", ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
y.label AS "yardLabel",
y.has_facility AS "hasFacility", private toInfo(row: {
f.has_warehouse AS "hasWarehouse" yardId: string;
FROM freight.yards y yardCode: string | null;
LEFT JOIN freight.yard_facilities f yardLabel: string | null;
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true hasFacility: boolean;
WHERE y.id = $1 AND y.deleted_at IS NULL`, hasWarehouse: boolean | null;
[yardId], handlesContainer: boolean | null;
); handlesBulk: boolean | null;
if (!row) return null; }): YardFacilityInfo {
// No facility record means no capability, whatever the flag says.
const hasFacility = Boolean(row.hasFacility);
return { return {
yardId: row.yardId, yardId: row.yardId,
yardCode: row.yardCode, yardCode: row.yardCode,
yardLabel: row.yardLabel, yardLabel: row.yardLabel,
hasFacility: Boolean(row.hasFacility), hasFacility,
// No facility record means no warehouse, whatever the flag says. hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
hasWarehouse: Boolean(row.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. */ /** Every yard that can load/unload, for pickers and the intercity queues. */
async listFacilityYards(): Promise<YardFacilityInfo[]> { async listFacilityYards(): Promise<YardFacilityInfo[]> {
const rows: Array<{ const rows = await this.dataSource.query(
yardId: string; `${this.SELECT}
yardCode: string | null; WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
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
ORDER BY y.display_order ASC, y.label ASC`, ORDER BY y.display_order ASC, y.label ASC`,
); );
return rows.map((r) => ({ return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
yardId: r.yardId, }
yardCode: r.yardCode,
yardLabel: r.yardLabel, /**
hasFacility: true, * Can this facility lift this cargo? Keeps the freight-type rule in one place
hasWarehouse: Boolean(r.hasWarehouse), * 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. */ /** Create a yard. */
async create(dto: CreateYardDto): Promise<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); const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${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; 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> { 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); 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> { findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
return this.repo(manager).findOne({ return this.repo(manager).findOne({
where: { id }, 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: { relations: {
// Yards carry the route's display name; without them formatRouteLabel // Yards carry the route's display name; without them formatRouteLabel
// degrades to the literal "Origin → Destination". Milestones (with // 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. // assertion below that says "not full" proves those axes are ignored.
const scheduleId = 'schedule-built'; const scheduleId = 'schedule-built';
const reservedBooking = (id: string) => const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
({ ({
id, id,
freightType: 'BULK', freightType: 'BULK',
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
bookingContainers: [], bookingContainers: [],
originYardId: 'yard-a', originYardId: leg?.origin ?? 'yard-a',
destinationYardId: 'yard-b', destinationYardId: leg?.dest ?? 'yard-b',
}) as unknown as Booking; }) as unknown as Booking;
const buildService = (opts: { const buildService = (opts: {
physicalWagons: number; physicalWagons: number;
reserved: Booking[]; reserved: Booking[];
maxWagons?: number; maxWagons?: number;
routeStops?: string[];
}) => { }) => {
const schedule = { const schedule = {
id: scheduleId, id: scheduleId,
@@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
bookingWindowStatus: 'OPEN', bookingWindowStatus: 'OPEN',
originStationId: 'yard-a', originStationId: 'yard-a',
destinationStationId: 'yard-b', destinationStationId: 'yard-b',
routeId: null, routeId: opts.routeStops ? 'route-1' : null,
scheduleBookings: [], scheduleBookings: [],
trainSet: { trainSet: {
locomotive: { locomotive: {
@@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
}, },
}; };
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) }; 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 = { const genericRepo = {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
}; };
const dataSource = { const dataSource = {
getRepository: jest.fn((entity: { name?: string }) => getRepository: jest.fn((entity: { name?: string }) => {
entity?.name === 'Wagon' ? wagonRepo : genericRepo, if (entity?.name === 'Wagon') return wagonRepo;
), if (entity?.name === 'RouteMilestone') return milestoneRepo;
return genericRepo;
}),
transaction: jest.fn(), transaction: jest.fn(),
}; };
const service = new BookingBatchService( const service = new BookingBatchService(
@@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); 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 () => { it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
const { service } = buildService({ const { service } = buildService({
physicalWagons: 1, 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 { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service'; import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; import { eatDay } from './batch-window.util';
import { import {
BATCH_BOARD_STATUSES, BATCH_BOARD_STATUSES,
BatchBoardQueryDto, BatchBoardQueryDto,
@@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Wagon } from '../wagons/entities/wagon.entity'; import { Wagon } from '../wagons/entities/wagon.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service'; import { BookingSplitService } from './booking-split.service';
import { RemainderPlacementService } from './remainder-placement.service';
import { BookingWindowGateway } from './booking-window.gateway'; import { BookingWindowGateway } from './booking-window.gateway';
import { import {
MAX_TEU_SLOTS_PER_WAGON, MAX_TEU_SLOTS_PER_WAGON,
@@ -171,23 +172,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
consolidationPartnerRef: string | null; consolidationPartnerRef: string | null;
} }
export interface BatchWindowGroup { export interface BatchBoardCounts {
key: string; allocated: number;
label: string; selectedForBatch: number;
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */ ready: number;
date: string; waiting: number;
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */ expired: number;
dateLabel: string; pendingContract: number;
start: string; }
end: string;
counts: { /** A booking bucket on the detail board (in-window vs pending-contract). */
allocated: number; export interface BatchBoardBucket {
selectedForBatch: number; counts: BatchBoardCounts;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
bookings: BatchBoardBookingDetail[]; bookings: BatchBoardBookingDetail[];
} }
@@ -214,8 +210,9 @@ export interface BatchBoardScheduleDetail {
locomotive: BatchBoardSchedule["locomotive"]; locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"]; capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"]; counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[]; /** Bookings inside the schedule's booking window (fully-executed contracts). */
pendingContract: BatchWindowGroup; bookings: BatchBoardBookingDetail[];
pendingContract: BatchBoardBucket;
allocationViolations: string[]; allocationViolations: string[];
} }
@@ -317,9 +314,29 @@ export class BookingBatchService implements OnModuleInit {
@Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService, @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. */ /** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
const groups = await this.openRouteDayGroups(); 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). // to the offered part before it boards (remainder returns to the contract cap).
if (this.splitService) { if (this.splitService) {
await this.splitService.applySplit(bookingId); 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 = 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, * 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 * 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 }; 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. * 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 * 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> { async acceptExportBooking(booking: Booking): Promise<void> {
const partnerId = booking.consolidationPartnerId ?? null; const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) { 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); const scheduleId = await this.pickExportSchedule(booking);
await this.reserveOnExport([booking], scheduleId); await this.reserveOnExport([booking], scheduleId);
return; return;
@@ -1013,11 +1189,32 @@ export class BookingBatchService implements OnModuleInit {
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); 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[] = []; const board: BatchBoardSchedule[] = [];
for (const s of schedules) { for (const s of schedules) {
const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set<string>();
const linkedIds = new Set(links.map((l) => l.bookingId)); const bookings = bookingsBySchedule.get(s.id) ?? [];
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
const items: BatchBoardBooking[] = bookings.map((b) => { const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims); const need = this.needFor(b, wagonDims);
@@ -1046,7 +1243,8 @@ export class BookingBatchService implements OnModuleInit {
return { items: board, meta: buildPaginationMeta(total, page, pageSize) }; 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( async getBatchBoardDetail(
scheduleId: string, scheduleId: string,
): Promise<BatchBoardScheduleDetail> { ): Promise<BatchBoardScheduleDetail> {
@@ -1064,17 +1262,24 @@ export class BookingBatchService implements OnModuleInit {
} }
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); // The full graph already carries the schedule↔booking links — no separate
const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); // link query needed.
const linkedIds = new Set(links.map((l) => l.bookingId)); const linkedIds = new Set(
(s.scheduleBookings ?? []).map((l) => l.bookingId),
);
const bookings = await this.bookingsRepository.findAllBySchedule(s.id); const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited< let allocationPreview: Awaited<
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]> ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
>; >;
try { try {
// Reuse the graph loaded above — the preview otherwise re-loads the same
// heavy schedule graph a second time per request.
allocationPreview = allocationPreview =
await this.trainSchedulingService.previewAllocationForSchedule(s.id); await this.trainSchedulingService.previewAllocationForSchedule(
s.id,
s,
);
} catch { } catch {
allocationPreview = { allocationPreview = {
assignedBookingIds: [], assignedBookingIds: [],
@@ -1144,73 +1349,22 @@ export class BookingBatchService implements OnModuleInit {
const loco = s.trainSet?.locomotive ?? null; const loco = s.trainSet?.locomotive ?? null;
// Display windows are the REAL booking-window cycles this schedule was FROZEN // The board renders ONE booking window the schedule's own frozen window
// with at creation (import: opens at its stored window time, lasts its rule's // (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
// duration, reopens per its rule's delay; export: single FCFS lead window) — // split into two buckets: contract executed (in the window) vs pending
// NOT the live global config. A later global-rules edit only re-derives // contract. The old per-cycle window projection was dropped — the UI never
// not-yet-open schedules (restampPendingWindows), so an already-open schedule // showed it, and reconstructing every cycle cost a config load + grouping
// must keep drawing from its own snapshot, anchored on its stored open time. // pass per request.
// Legacy rows with no snapshot fall back to the live config. const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => {
const liveCfg = await this.trainSchedulingService.getWindowConfig(); const counts: BatchBoardCounts = {
const num = (v: unknown, fallback: number) => { allocated: 0,
const n = v == null ? NaN : Number(v); selectedForBatch: 0,
return Number.isFinite(n) ? n : fallback; ready: 0,
}; waiting: 0,
const windowCfg = { expired: 0,
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), pendingContract: 0,
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour), };
windowDurationHours: num( for (const b of bucket) {
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) {
if (b.state === "ALLOCATED") counts.allocated += 1; if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === "READY") counts.ready += 1; else if (b.state === "READY") counts.ready += 1;
@@ -1221,26 +1375,8 @@ export class BookingBatchService implements OnModuleInit {
return counts; return counts;
}; };
const windows: BatchWindowGroup[] = []; const windowBookings = items.filter((i) => i.fullyExecutedAt);
for (const [key, bucket] of windowBuckets) { const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
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 ?? [];
return { return {
scheduleId: s.id, scheduleId: s.id,
@@ -1290,14 +1426,8 @@ export class BookingBatchService implements OnModuleInit {
.length, .length,
expired: items.filter((i) => i.state === "EXPIRED").length, expired: items.filter((i) => i.state === "EXPIRED").length,
}, },
windows, bookings: windowBookings,
pendingContract: { pendingContract: {
key: "pending-contract",
label: "Pending contract",
date: "",
dateLabel: "",
start: "",
end: "",
counts: countFor(pendingBookings), counts: countFor(pendingBookings),
bookings: 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 * A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a
* offered a partial (split-on-payment). Consolidated pairs never split (both-or- * partial (split-on-payment). Consolidated pairs never split (both-or-neither
* neither shared wagon) and government bookings never split (they preempt). * 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 { private isSplitEligible(booking: Booking, isPair: boolean): boolean {
const directionOk =
booking.tradeDirection === "IMPORT" ||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
return ( return (
!isPair && !isPair &&
!booking.isGovernment && !booking.isGovernment &&
booking.tradeDirection === "IMPORT" && directionOk &&
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
this.splitService != null this.splitService != null
); );
@@ -2323,7 +2461,13 @@ export class BookingBatchService implements OnModuleInit {
if (!schedule || !locomotive) return null; if (!schedule || !locomotive) return null;
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive); 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) }; 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 * (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
* no wagon type configured yet. * 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> { 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 types = await this.dataSource.getRepository(WagonType).find();
const byCode = new Map( const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), 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. // must fall back rather than yield an infinite wagon count.
const payload = (value: number | undefined, fallback: number): number => const payload = (value: number | undefined, fallback: number): number =>
value && value > 0 ? value : fallback; value && value > 0 ? value : fallback;
return { const value: WagonDims = {
container: { container: {
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
@@ -3144,6 +3295,8 @@ export class BookingBatchService implements OnModuleInit {
}, },
byWagonTypeId, 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 → * Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent. * destination); the legacy two-stop pseudo-route when milestones are absent.
@@ -3211,6 +3398,7 @@ export class BookingBatchService implements OnModuleInit {
schedule: TrainSchedule, schedule: TrainSchedule,
limits: TrainLimits, limits: TrainLimits,
wagonDims: WagonDims, wagonDims: WagonDims,
opts?: { collapseForBuiltTrain?: boolean },
): Promise<CorridorBudget> { ): Promise<CorridorBudget> {
const physicalWagons = await this.builtTrainWagonCount(schedule); const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) { if (physicalWagons != null) {
@@ -3223,7 +3411,21 @@ export class BookingBatchService implements OnModuleInit {
tolerance: { weightTons: 0, lengthMeters: 0 }, 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 budget = new CorridorBudget(stops, limits.base, limits.tolerance);
const allocated = (schedule.scheduleBookings ?? []) const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking) .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. */ /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> { 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; 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; const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against if (!locomotive) return false; // no weight/length limits to bind against
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();
@@ -3398,6 +3608,29 @@ export class BookingBatchService implements OnModuleInit {
return budget.isExhausted(this.minPerWagonNeed(wagonDims)); 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 * Smallest gross weight / shortest length one more wagon could add: the
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,

View File

@@ -352,10 +352,19 @@ export class BookingJourneyService {
): Promise<void> { ): Promise<void> {
if (booking.tradeDirection !== 'DOMESTIC') return; if (booking.tradeDirection !== 'DOMESTIC') return;
const facility = await this.yardFacilities.facilityForYard(yardId); const facility = await this.yardFacilities.facilityForYard(yardId);
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
if (!facility?.hasFacility) { if (!facility?.hasFacility) {
throw new BadRequestException( throw new BadRequestException(
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` + `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} 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> { ): Promise<void> {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); 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 = const msg =
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + `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. ` + `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.`; `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)'); await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
// HIGH: a split is a change to what the customer ordered AND a live payment // 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 { secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
void (async () => { void (async () => {
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); 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, Min,
} from 'class-validator'; } from 'class-validator';
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
export class RecordCheckpointDto { export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' }) @ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt() @IsInt()
@@ -21,9 +23,18 @@ export class RecordCheckpointDto {
@IsEnum(TrainCheckpointKind) @IsEnum(TrainCheckpointKind)
kind?: 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() @IsOptional()
@IsISO8601() @IsISO8601()
@IsNotBackdated()
occurredAt?: string; occurredAt?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })

View File

@@ -67,10 +67,18 @@ export class IntercityService {
ts.status AS "scheduleStatus", ts.status AS "scheduleStatus",
oy.id AS "originYardId", oy.id AS "originYardId",
COALESCE(oy.label, oy.code) AS "origin", 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", dy.id AS "destinationYardId",
COALESCE(dy.label, dy.code) AS "destination", 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 -- Where the train actually is, so the operator knows if the cargo
-- can be worked right now. -- can be worked right now.
cp.yard_id AS "trainAtYardId", 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.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_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.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 LEFT JOIN freight.train_schedules ts
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -104,7 +116,8 @@ export class IntercityService {
async listCandidates(scheduleId: string) { async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId); 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 capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
const waiting = milestoneSeq const waiting = milestoneSeq
@@ -112,29 +125,45 @@ export class IntercityService {
: []; : [];
const accepted = await this.findAcceptedIntercityBookings(scheduleId); 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 { return {
scheduleId, scheduleId,
routeId: schedule.routeId ?? null, routeId: schedule.routeId ?? null,
warning,
// Segment-based: "remaining" is the most-open edge; each candidate's // 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 // `fits` is judged against ITS OWN leg, so a booking on a free leg fits
// even when the train is full elsewhere. // even when the train is full elsewhere.
remaining: capacity?.budget.maxRemaining() ?? null, remaining: capacity?.budget.maxRemaining() ?? null,
candidates: waiting.map((booking) => { candidates: waiting.map((booking) => {
const need = capacity?.needFor(booking) ?? null; const need = capacity?.needFor(booking) ?? null;
const leg = capacity?.budget.legOf( // 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.originYardId,
booking.destinationYardId, booking.destinationYardId,
); );
return { return {
...this.mapBooking(booking), ...this.mapBooking(booking, need),
need, need,
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
}; };
}), }),
accepted: accepted.map((booking) => ({ accepted: accepted.map((booking) => {
...this.mapBooking(booking), const need = capacity?.needFor(booking) ?? null;
need: capacity?.needFor(booking) ?? null, return { ...this.mapBooking(booking, need), need };
})), }),
}; };
} }
@@ -186,14 +215,17 @@ export class IntercityService {
continue; continue;
} }
const need = capacity.needFor(booking); const need = capacity.needFor(booking);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId); // legForYards, not legOf: a built train's budget is a single whole-route
// Segment-based: only the booking's own leg must have room, so an // pool (mid-corridor wagons are committed for the whole trip and never
// intercity booking still boards a train that is full on other legs. // reloaded), so the booking draws from that pool via the whole-route
if (!leg || !budget.fits(need, leg)) { // 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({ rejected.push({
bookingId, bookingId,
reason: 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; continue;
} }
@@ -244,16 +276,21 @@ export class IntercityService {
* so an intercity booking exactly matching the train's own corridor still * so an intercity booking exactly matching the train's own corridor still
* qualifies. * qualifies.
*/ */
private async routeMilestoneSequence( private async routeMilestones(
schedule: TrainSchedule, schedule: TrainSchedule,
): Promise<Map<string, number> | null> { ): Promise<RouteMilestone[]> {
if (schedule.routeId) { if (!schedule.routeId) return [];
const milestones = await this.dataSource return this.dataSource
.getRepository(RouteMilestone) .getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) { }
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
} 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) { if (schedule.originStationId && schedule.destinationStationId) {
return new Map([ return new Map([
@@ -264,6 +301,15 @@ export class IntercityService {
return null; 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. */ /** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
private async findWaitingIntercityBookings( private async findWaitingIntercityBookings(
milestoneSeq: Map<string, number>, milestoneSeq: Map<string, number>,
@@ -356,7 +402,12 @@ export class IntercityService {
return { schedule, booking }; 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 { return {
id: booking.id, id: booking.id,
reference: booking.reference, reference: booking.reference,
@@ -372,7 +423,7 @@ export class IntercityService {
booking.destinationYard?.label ?? booking.destinationYard?.label ??
booking.destinationYard?.code ?? booking.destinationYard?.code ??
'Unknown destination', 'Unknown destination',
weightTons: Number(booking.cargoTotalWeightVgm ?? 0), weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null, 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") @Get("batch-board/:scheduleId")
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ @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) { getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
return this.bookingBatchService.getBatchBoardDetail(scheduleId); 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 { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingJourneyService } from './booking-journey.service'; import { BookingJourneyService } from './booking-journey.service';
import { BookingSplitService } from './booking-split.service'; import { BookingSplitService } from './booking-split.service';
import { RemainderPlacementService } from './remainder-placement.service';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
@@ -82,6 +83,7 @@ import { ContractsModule } from '../contracts/contracts.module';
WsAuthService, WsAuthService,
BookingWindowService, BookingWindowService,
BookingSplitService, BookingSplitService,
RemainderPlacementService,
IntercityService, IntercityService,
BookingJourneyService, BookingJourneyService,
FacilityHandlingService, FacilityHandlingService,

View File

@@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => {
expect(result).toHaveLength(2); 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; freightType: string | null;
priorityScore: number; priorityScore: number;
cargoTotalWeightVgm: number; cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null; status: string | null;
schedulingStatus: string | null; schedulingStatus: string | null;
wagonsRequired: number; wagonsRequired: number;
@@ -477,6 +479,38 @@ export class TrainSchedulingService {
return qb.getMany(); 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 * 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 * 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 // 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. // the live config where a legacy row has no snapshot) against the new date.
const merged = effectiveWindowConfig(schedule, windowCfg); const merged = effectiveWindowConfig(schedule, windowCfg);
@@ -913,10 +969,37 @@ export class TrainSchedulingService {
scheduledDepartureDate: departure, scheduledDepartureDate: departure,
...windowFields, ...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( this.logger.log(
`Departure date changed for schedule ${id}${departure.toISOString()} ` + `Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` + `(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); void this.emitWindowState(id);
@@ -1200,7 +1283,8 @@ export class TrainSchedulingService {
} }
if ( if (
builtTrain.status === Freight.TrainStatus.OutOfService || builtTrain.status === Freight.TrainStatus.OutOfService ||
builtTrain.status === Freight.TrainStatus.UnderMaintenance builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
builtTrain.status === Freight.TrainStatus.Deactivated
) { ) {
throw new ConflictException( throw new ConflictException(
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`, `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`, `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 { } else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])]; locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 2) { if (locomotiveIds.length < 2) {
@@ -1679,6 +1774,7 @@ export class TrainSchedulingService {
scheduleId, scheduleId,
schedule.originStationId, schedule.originStationId,
savedWagons, savedWagons,
schedule.reverseWagonOrder ?? false,
); );
}); });
@@ -2590,19 +2686,24 @@ export class TrainSchedulingService {
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
totalBookings: schedule.scheduleBookings?.length ?? 0, totalBookings: schedule.scheduleBookings?.length ?? 0,
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({ // Every wagon on the train set, loaded or not, in consist order. An empty
sequenceNo: wagon.sequenceNo, // wagon has an empty `allocations` array — it is still part of the train
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, // and still belongs on the marshalling document.
allocations: (wagon.allocations ?? []).map((allocation) => ({ wagons: [...(schedule.trainSet?.wagons ?? [])]
bookingId: allocation.bookingId, .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
bookingReference: allocation.booking?.reference ?? null, .map((wagon) => ({
loadType: allocation.loadType ?? null, sequenceNo: wagon.sequenceNo,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
containerNumbers: (allocation.containerItems ?? []) allocations: (wagon.allocations ?? []).map((allocation) => ({
.map((item) => item.containerNumber) bookingId: allocation.bookingId,
.filter(Boolean), 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), 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 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 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 bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
const rows = (schedule.trainSet?.wagons ?? []) // The document is checked against the physical train, so it has to run in
.flatMap((wagon) => // consist order — the relation comes back unordered.
(wagon.allocations ?? []).map((allocation) => { 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 booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const company = booking?.company as Record<string, unknown> | null | undefined; const company = booking?.company as Record<string, unknown> | null | undefined;
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; 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 sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr> return `<tr>
<td>${esc(wagon.sequenceNo)}</td> ${wagonCells}
<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>
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td> <td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
<td>${esc(booking?.companyId)}</td> <td>${esc(booking?.companyId)}</td>
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td> <td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
@@ -2677,10 +2797,11 @@ export class TrainSchedulingService {
<td>${esc(chassisNumbers)}</td> <td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td> <td>${esc(sealNumbers)}</td>
</tr>`; </tr>`;
}), });
) })
.join(''); .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) =>
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0, 0,
@@ -2708,6 +2829,8 @@ export class TrainSchedulingService {
th { background: #f8fafc; color: #475569; text-align: left; } th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; } .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; } .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; } .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; } .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>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>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>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>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>Status</span><strong>${esc(schedule.status)}</strong></div>
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div> <div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
@@ -2759,7 +2882,7 @@ export class TrainSchedulingService {
</tr> </tr>
</thead> </thead>
<tbody> <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> </tbody>
</table> </table>
@@ -2802,19 +2925,31 @@ export class TrainSchedulingService {
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0, 0,
); );
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
const allocationRows = loadList.wagons const allocationRows = loadList.wagons
.flatMap((wagon) => .flatMap((wagon) => {
wagon.allocations.map( 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> (allocation) => `<tr>
<td>${esc(wagon.sequenceNo)}</td> ${wagonCells}
<td>${esc(wagon.wagonNumber)}</td>
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td> <td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(allocation.loadType)}</td> <td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td> <td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td> <td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`, </tr>`,
), );
) })
.join(''); .join('');
return `<!doctype html> return `<!doctype html>
@@ -2846,6 +2981,8 @@ export class TrainSchedulingService {
th { background: #f8fafc; color: #475569; text-align: left; } th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; } th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
.num { text-align: right; } .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; } .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; } .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; } .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>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</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>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>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>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> <div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
@@ -2900,7 +3037,7 @@ export class TrainSchedulingService {
</tr> </tr>
</thead> </thead>
<tbody> <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> </tbody>
</table> </table>
@@ -3866,11 +4003,18 @@ export class TrainSchedulingService {
} }
const totalWeightTons = totalAssignedWeight(fittingBookings); 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( const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
); );
if (totalWeightTons > trainLimits.maxWeightTons) { if (grossWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; 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)) { if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]); pushLimit([message]);
} }
@@ -3897,7 +4041,7 @@ export class TrainSchedulingService {
if ( if (
setLimits && setLimits &&
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
totalWeightTons || grossWeightTons ||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
totalLengthMeters) totalLengthMeters)
) { ) {
@@ -3918,7 +4062,7 @@ export class TrainSchedulingService {
!inServiceLocomotives.some( !inServiceLocomotives.some(
(l) => (l) =>
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
totalWeightTons && grossWeightTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
totalLengthMeters, totalLengthMeters,
) )
@@ -3940,6 +4084,9 @@ export class TrainSchedulingService {
summary: { summary: {
totalBookings: fittingBookings.length, totalBookings: fittingBookings.length,
totalWeightTons, 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. // Human-readable wagon type(s) of the plan — mixed consists list all.
wagonType: plannedTypeCodes.join('/') || 'NONE', wagonType: plannedTypeCodes.join('/') || 'NONE',
wagonsNeeded: wagonPlan.length, wagonsNeeded: wagonPlan.length,
@@ -4271,6 +4418,7 @@ export class TrainSchedulingService {
scheduleId: string, scheduleId: string,
originYardId: string, originYardId: string,
slots: TrainSetWagon[], slots: TrainSetWagon[],
reverseWagonOrder = false,
) { ) {
const wagons = await manager.getRepository(Wagon).find(); const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find(); const wagonTypes = await manager.getRepository(WagonType).find();
@@ -4316,6 +4464,7 @@ export class TrainSchedulingService {
assignedPhysicalIds, assignedPhysicalIds,
builtTrainId, builtTrainId,
pinnedToScheduleIds, pinnedToScheduleIds,
reverseWagonOrder,
); );
if (!physical) continue; if (!physical) continue;
@@ -4409,6 +4558,7 @@ export class TrainSchedulingService {
assignedPhysicalIds: Set<string>, assignedPhysicalIds: Set<string>,
builtTrainId: string | null = null, builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(), pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
): Wagon | undefined { ): Wagon | undefined {
const usable = (wagon: Wagon): boolean => { const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false; 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 // wherever they currently sit (they travel with the train), never a loose
// yard wagon. // yard wagon.
if (builtTrainId) { if (builtTrainId) {
return wagons.find( // Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
(w) => // consist views draw the schedule exactly like the train builder; a schedule
w.trainId === builtTrainId && // created with reverseWagonOrder pins back-to-front (physically-last wagon
w.wagonTypeId === slot.wagonTypeId && // takes slot #1). Unsequenced wagons sort after every sequenced one.
!assignedPhysicalIds.has(w.id), 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); // Prefer a wagon already waiting at the slot's board yard (no empty haul);
// fall back to one riding from the train's origin. // 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({ const trains = await this.dataSource.getRepository(Train).find({
where: { where: {
status: Not( status: Not(
In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]), In([
Freight.TrainStatus.OutOfService,
Freight.TrainStatus.UnderMaintenance,
Freight.TrainStatus.Deactivated,
]),
), ),
}, },
relations: { relations: {
@@ -5497,8 +5665,8 @@ export class TrainSchedulingService {
* Re-derive a built train's lifecycle status from its schedules after one of * Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival * SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE) * at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
* keep their status — staff own that flag, not the scheduler. * / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
*/ */
private async syncBuiltTrainAfterScheduleChange( private async syncBuiltTrainAfterScheduleChange(
manager: EntityManager, manager: EntityManager,
@@ -6208,11 +6376,21 @@ export class TrainSchedulingService {
* engine's representative fallbacks for bookings whose cargo/container type * engine's representative fallbacks for bookings whose cargo/container type
* has no wagon type configured. Loaded once per request before mapping. * 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<{ private async loadWagonTareDims(): Promise<{
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>; byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
bulk: { tareWeightTons: number; capacityTons: number }; bulk: { tareWeightTons: number; capacityTons: number };
container: { 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 types = await this.dataSource.getRepository(WagonType).find();
const byWagonTypeId = new Map( const byWagonTypeId = new Map(
types.map((t) => [ types.map((t) => [
@@ -6223,7 +6401,7 @@ export class TrainSchedulingService {
}, },
]), ]),
); );
return { const value = {
byWagonTypeId, byWagonTypeId,
bulk: { bulk: {
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
@@ -6234,6 +6412,8 @@ export class TrainSchedulingService {
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, 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 allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); 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 // Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure // (loadedOnTrainAt on the operation). Other directions have no departure
// loading gate, so the workspace shows the confirm button as already done. // loading gate, so the workspace shows the confirm button as already done.
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule); 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 // Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
// and re-pinned onto later trains — the live wagon↔slot joins no longer // 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, // 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]), (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 // The trainSet slots below are the PLANNED wagons (one per allocation). A
// schedule tied to a built train hauls EVERY coupled wagon — empty ones // schedule tied to a built train hauls EVERY coupled wagon — empty ones
// included (the pull-limit check already counts their tare) — so append the // included (the pull-limit check already counts their tare) — so append the
@@ -6369,41 +6563,32 @@ export class TrainSchedulingService {
0, 0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
); );
const emptyConsistWagons = const emptyConsistWagons = rawConsistWagons
schedule.trainSet?.trainId && !isWagonAllocationFrozen .filter((wagon) => !coveredPhysicalIds.has(wagon.id))
? ( .map((wagon, index) => ({
await this.dataSource.getRepository(Wagon).find({ // Physical wagon id — there is no TrainSetWagon slot behind this
where: { trainId: schedule.trainSet.trainId }, // row, so remove/edit affordances must stay disabled (consistOnly).
relations: { wagonType: true }, id: wagon.id,
order: { sequenceNumber: 'ASC' }, sequenceNo: maxSlotSequenceNo + index + 1,
}) capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
) lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
.filter((wagon) => !coveredPhysicalIds.has(wagon.id)) assignedWeightTons: 0,
.map((wagon, index) => ({ tareWeightTons: wagon.wagonType
// Physical wagon id — there is no TrainSetWagon slot behind this ? roundTons(Number(wagon.wagonType.tareWeightTons))
// row, so remove/edit affordances must stay disabled (consistOnly). : null,
id: wagon.id, status: 'EMPTY',
sequenceNo: maxSlotSequenceNo + index + 1, physicalWagonId: wagon.id,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), physicalWagonNumber: wagon.wagonNumber ?? null,
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), wagonType: wagon.wagonType
assignedWeightTons: 0, ? {
tareWeightTons: wagon.wagonType id: wagon.wagonType.id,
? roundTons(Number(wagon.wagonType.tareWeightTons)) code: wagon.wagonType.code,
: null, name: wagon.wagonType.name,
status: 'EMPTY', }
physicalWagonId: wagon.id, : null,
physicalWagonNumber: wagon.wagonNumber ?? null, allocations: [],
wagonType: wagon.wagonType consistOnly: true,
? { }));
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}))
: [];
return { return {
id: schedule.id, id: schedule.id,
@@ -6413,6 +6598,7 @@ export class TrainSchedulingService {
trainNumber: schedule.trainNumber ?? null, trainNumber: schedule.trainNumber ?? null,
maxWagons: schedule.maxWagons ?? null, maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null, direction: schedule.direction ?? null,
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
requiresLoadingConfirmation, requiresLoadingConfirmation,
loadingConfirmed, loadingConfirmed,
// Booking-window phase + phase deadlines drive the countdown timers in the // 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. */ /** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule( async previewAllocationForSchedule(
scheduleId: string, 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> { ): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); throw new NotFoundException(`Train schedule ${scheduleId} not found`);
} }
@@ -6761,7 +6952,10 @@ export class TrainSchedulingService {
); );
if (!eligible.length) return empty; if (!eligible.length) return empty;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id); const wagonAssignedIds = await this.getWagonAssignedBookingIds(
schedule.id,
schedule,
);
const previewDto = { const previewDto = {
bookingIds: eligible.map((b) => b.id), bookingIds: eligible.map((b) => b.id),
scheduleDate: schedule.scheduledDepartureDate.toISOString(), scheduleDate: schedule.scheduledDepartureDate.toISOString(),
@@ -7036,6 +7230,15 @@ export class TrainSchedulingService {
shortfall: 0, 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( const bookings = await Promise.all(
unassigned.map(async (b) => { unassigned.map(async (b) => {
const assignability = await this.previewUnassignedBookingAssignability( const assignability = await this.previewUnassignedBookingAssignability(
@@ -7050,6 +7253,11 @@ export class TrainSchedulingService {
freightType: b.freightType ?? null, freightType: b.freightType ?? null,
priorityScore: b.priorityScore ?? 0, priorityScore: b.priorityScore ?? 0,
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 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, status: b.status ?? null,
schedulingStatus: b.schedulingStatus ?? null, schedulingStatus: b.schedulingStatus ?? null,
...assignability, ...assignability,
@@ -7278,8 +7486,15 @@ export class TrainSchedulingService {
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId); return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
} }
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> { private async getWagonAssignedBookingIds(
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); 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); const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.length) return new Set(); if (!wagonIds.length) return new Set();

View File

@@ -44,6 +44,15 @@ export class TrainBuilderController {
return this.trainBuilderService.listBuilt(query); 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') @Get(':id')
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' }) @ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
composition(@Param('id', ParseUUIDPipe) id: string) { composition(@Param('id', ParseUUIDPipe) id: string) {
@@ -115,6 +124,22 @@ export class TrainBuilderController {
return this.trainBuilderService.reorderWagons(id, dto); 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') @Delete(':id')
@FleetManage() @FleetManage()
@HttpCode(HttpStatus.NO_CONTENT) @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, * One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
* else the earliest upcoming departure) — feeds the list's direction tint * else the earliest upcoming departure) — feeds the list's direction tint
@@ -532,6 +564,50 @@ export class TrainBuilderService {
return this.getComposition(id); 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. */ /** Disband the train: release wagons and locomotives, then delete it. */
async disband(id: string): Promise<void> { async disband(id: string): Promise<void> {
await this.dataSource.transaction(async (manager) => { 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). // 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
const [schedule] = await this.dataSource.query( 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 FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
@@ -1957,14 +1958,20 @@ export class WarehouseInventoryService {
tradeDirection: string | null; tradeDirection: string | null;
cargoTypeCode: string | null; cargoTypeCode: string | null;
}[] = await this.dataSource.query( }[] = 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, `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode" cgt.code AS "cargoTypeCode"
FROM freight.train_schedule_bookings tsb FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL 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 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`, WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
[scheduleId], AND b.destination_yard_id = $2`,
[scheduleId, schedule.destinationStationId],
); );
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null; 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.", "Minimum days between today and the vessel departure date on an export Release Order.",
multiple: false, 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() @Injectable()

View File

@@ -13,11 +13,13 @@ const INDODE_FACILITY = {
facilityType: 'DRY_PORT' as const, facilityType: 'DRY_PORT' as const,
facilityStatus: 'ACTIVE' as const, facilityStatus: 'ACTIVE' as const,
locationName: 'Indode', locationName: 'Indode',
country: 'Djibouti', // Indode is the Gelan Multipurpose Port outside Addis — the yard carries it as
city: 'Djibouti', // KALITY, country Ethiopia. It was seeded as Djibouti, which is the wrong end
address: 'Indode, Djibouti', // of the line. Coordinates are left unset rather than guessed; fill them in
latitude: 11.5447, // when the real position is to hand.
longitude: 43.145, country: 'Ethiopia',
city: 'Addis Ababa',
address: 'Indode (Gelan), Addis Ababa, Ethiopia',
capacity: 50000, capacity: 50000,
isActive: true, isActive: true,
notes: 'Primary dry port for container consolidation and distribution', notes: 'Primary dry port for container consolidation and distribution',
@@ -72,20 +74,22 @@ export class IndodeFacilitySeeder {
const yardRepo = manager.getRepository(WarehouseYard); const yardRepo = manager.getRepository(WarehouseYard);
const zoneRepo = manager.getRepository(WarehouseZone); const zoneRepo = manager.getRepository(WarehouseZone);
// Ensure facility exists // Ensure facility exists. An existing facility row is not proof the
const facility = await facilityRepo.findOne({ // 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 }, where: { code: INDODE_FACILITY.code },
}); });
if (facility) { let savedFacility: Facility;
this.logger.log('Indode facility already exists, skipping seed'); if (existing) {
return; 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 // Create warehouses for the facility
for (const warehouseData of WAREHOUSES) { for (const warehouseData of WAREHOUSES) {
try { 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 * Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
* settled which is the intercity facility. * settled which is the intercity facility.
*/ */
const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [ const FACILITY_YARDS: Array<{
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true }, code: string;
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false }, facility: string;
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false }, hasWarehouse: boolean;
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false }, handlesContainer: boolean;
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false }, }> = [
// 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() @Injectable()
@@ -35,7 +42,7 @@ export class YardFacilitiesSeeder {
* yards — a missing code is logged and skipped rather than invented. * yards — a missing code is logged and skipped rather than invented.
*/ */
async run(): Promise<void> { 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( const [yard]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`, `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
[code], [code],
@@ -53,11 +60,15 @@ export class YardFacilitiesSeeder {
); );
await this.dataSource.query( await this.dataSource.query(
`INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes) `INSERT INTO freight.yard_facilities
VALUES ($1, $2, $3) (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 ON CONFLICT (yard_id) WHERE deleted_at IS NULL
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`, DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
[yard.id, hasWarehouse, `${facility} load/unload facility`], handles_container = EXCLUDED.handles_container,
handles_bulk = EXCLUDED.handles_bulk,
updated_at = NOW()`,
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
); );
} }
this.logger.log( this.logger.log(

View File

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

View File

@@ -15,10 +15,23 @@ import {
} from "./cookies"; } from "./cookies";
import type { AuthTokens } from "./types"; 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 = { type RetriableRequest = {
_retry?: boolean; _retry?: boolean;
headers?: Record<string, string>; headers?: Record<string, string>;
url?: string; url?: string;
suppressErrorModal?: boolean;
}; };
const api = axios.create({ const api = axios.create({
@@ -100,8 +113,13 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/refresh-token") originalRequest.url?.includes("/auth/refresh-token")
) { ) {
// Surface the server's actual error message in the global error modal // Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). // (401s are handled by the session-refresh flow, so skip them). A request
if (error.response && error.response.status !== 401) { // may opt out via `suppressErrorModal` when it handles the failure itself.
if (
error.response &&
error.response.status !== 401 &&
!originalRequest?.suppressErrorModal
) {
const payload = extractApiErrorPayload(error); const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload); 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 type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner"; import { NextStepBanner } from "@/components/bookings/NextStepBanner";
@@ -94,9 +95,15 @@ export function BookingRequestHero({
Booking reference Booking reference
</Text> </Text>
<Group gap="sm" align="center" wrap="wrap"> <Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}> <Stack gap={2} miw={0}>
{booking.reference} <Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
</Title> {booking.reference}
</Title>
<ContractReferenceLink
contractId={booking.contractId}
contractReference={booking.contractReference}
/>
</Stack>
<BookingStatusBadge status={booking.status} /> <BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} /> <BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? ( {booking.schedulingStatus ? (

View File

@@ -4,6 +4,7 @@ import {
useRef, useRef,
useState, useState,
type KeyboardEvent, type KeyboardEvent,
type ReactNode,
} from "react"; } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@@ -37,10 +38,12 @@ import {
FileDown, FileDown,
FileText, FileText,
FileUp, FileUp,
Flame,
MapPin, MapPin,
Package, Package,
Receipt, Receipt,
Repeat, Repeat,
Snowflake,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -128,6 +131,10 @@ interface UnitDraft {
containerNumber: string; containerNumber: string;
sealNumber: string; sealNumber: string;
vgmTons: 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 + /** Mirrors the portal shipment form's container line: line-level quantity +
@@ -150,7 +157,14 @@ interface BulkDraft {
} }
function emptyUnit(): UnitDraft { function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" }; return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
} }
function emptyLine(size: string): ContainerLineDraft { function emptyLine(size: string): ContainerLineDraft {
@@ -285,6 +299,41 @@ export default function GlCreateBookingForm() {
// Legacy contracts (no equipment return chosen at creation) keep the old // Legacy contracts (no equipment return chosen at creation) keep the old
// booking-level toggle. // booking-level toggle.
const legacyReturnToggle = isContainer && !contract?.equipmentReturn; 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 // Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies. // finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC"; const isIntercity = contract?.tradeDirection === "DOMESTIC";
@@ -358,6 +407,9 @@ export default function GlCreateBookingForm() {
useEffect(() => { useEffect(() => {
if (!bookingRequest || prefilled) return; 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); setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {}; const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) { if (lines.containers?.length) {
@@ -399,13 +451,27 @@ export default function GlCreateBookingForm() {
setContainerLines( setContainerLines(
lines.map((c) => { lines.map((c) => {
const qty = Math.max(1, c.quantity); 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 { return {
containerSize: String(c.containerType?.sizeFt ?? ""), containerSize: String(c.containerType?.sizeFt ?? ""),
quantity: String(qty), quantity: String(qty),
hazardousQuantity: "0", hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: "0", reeferQuantity: String(units.filter((u) => u.isReefer).length),
returnQuantity: "0", returnQuantity: String(units.filter((u) => u.isReturn).length),
units: Array.from({ length: qty }, emptyUnit), units,
}; };
}), }),
); );
@@ -488,6 +554,18 @@ export default function GlCreateBookingForm() {
enabled: cargoQuery !== null && !isIntercity, 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. // Keep the units array length in sync with the entered quantity.
const syncUnits = (lineIdx: number, qty: number) => { const syncUnits = (lineIdx: number, qty: number) => {
setContainerLines((prev) => setContainerLines((prev) =>
@@ -496,7 +574,7 @@ export default function GlCreateBookingForm() {
const next = [...line.units]; const next = [...line.units];
while (next.length < qty) next.push(emptyUnit()); while (next.length < qty) next.push(emptyUnit());
next.length = Math.max(0, qty); 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, unitIdx: number,
patch: Partial<UnitDraft>, patch: Partial<UnitDraft>,
) => ) =>
patchLine(lineIdx, { setContainerLines((prev) =>
units: containerLines[lineIdx].units.map((u, i) => prev.map((l, i) =>
i === unitIdx ? { ...u, ...patch } : u, 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 // Same client-side validation as the customer portal shipment form
// (new-shipment-form/schema.ts): ISO container numbers unique within the // (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), hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length), reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: String(imported.filter((r) => r.withReturn).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) => ({ units: imported.map((r) => ({
containerNumber: r.containerNumber, containerNumber: r.containerNumber,
sealNumber: r.sealNumber, sealNumber: r.sealNumber,
vgmTons: String(r.vgmTons), 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(), containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0, 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 { } else {
@@ -1175,78 +1268,60 @@ export default function GlCreateBookingForm() {
radius={10} radius={10}
styles={fieldStyles} 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> </Group>
<StepLabel>Per-container details</StepLabel> <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}> <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) => ( {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 <TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567" placeholder="e.g. MSCU1234567"
value={unit.containerNumber} value={unit.containerNumber}
error={ error={
@@ -1262,9 +1337,9 @@ export default function GlCreateBookingForm() {
} }
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }}
/> />
<TextInput <TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
placeholder="Optional" placeholder="Optional"
value={unit.sealNumber} value={unit.sealNumber}
onChange={(e) => onChange={(e) =>
@@ -1274,11 +1349,11 @@ export default function GlCreateBookingForm() {
} }
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }}
/> />
<TextInput <TextInput
type="number" type="number"
onKeyDown={blockNegative} onKeyDown={blockNegative}
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5" placeholder="e.g. 24.5"
min={0} min={0}
step={0.01} step={0.01}
@@ -1295,7 +1370,32 @@ export default function GlCreateBookingForm() {
} }
radius={10} radius={10}
styles={fieldStyles} 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> </Group>
))} ))}
</Stack> </Stack>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Alert, Alert,
Badge, Badge,
@@ -72,6 +72,7 @@ export type ClearanceViewLike = Pick<
| "linkedBookingId" | "linkedBookingId"
| "riskLevel" | "riskLevel"
| "riskAssignedAt" | "riskAssignedAt"
| "riskHistory"
| "secondDuty" | "secondDuty"
| "importReleaseGranted" | "importReleaseGranted"
> & { operationReady?: boolean }; > & { operationReady?: boolean };
@@ -1040,11 +1041,28 @@ function RiskStep({
done: boolean; done: boolean;
onChanged?: () => void; 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); const [loading, setLoading] = useState(false);
if (done || clearance.riskLevel) { // The clearance view loads (and refetches after a reassignment) after first
return ( // 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"> <Group gap="sm">
<Badge <Badge
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"} color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
@@ -1061,12 +1079,35 @@ function RiskStep({
. The customer can see this level. . The customer can see this level.
</Text> </Text>
</Group> </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 // Customs cannot rate cargo still under transit — the server rejects the
// assignment until the T1 is closed, so do not offer the control yet. // assignment until the T1 is closed, so do not offer the control yet. Skipped
if (!clearance.t1?.closed) { // 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 ( return (
<StepStatus <StepStatus
done={false} done={false}
@@ -1088,6 +1129,7 @@ function RiskStep({
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{assignedSummary}
<SegmentedControl <SegmentedControl
fullWidth fullWidth
value={level} value={level}
@@ -1100,19 +1142,24 @@ function RiskStep({
/> />
<Group justify="space-between"> <Group justify="space-between">
<Text size="xs" c="dimmed"> <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> </Text>
<Button <Button
size="compact-sm" size="compact-sm"
color="edr-green" color="edr-green"
loading={loading} loading={loading}
disabled={assigned && level === clearance.riskLevel}
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);
try { try {
await contractsService.assignRisk(bookingId, { await contractsService.assignRisk(bookingId, {
riskLevel: level as Freight.CustomsRiskLevel, riskLevel: level as Freight.CustomsRiskLevel,
}); });
toast.success("Customs risk assigned"); toast.success(
assigned ? "Customs risk reassigned" : "Customs risk assigned",
);
onChanged?.(); onChanged?.();
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Failed"); toast.error(e instanceof Error ? e.message : "Failed");
@@ -1121,7 +1168,7 @@ function RiskStep({
} }
}} }}
> >
Assign risk {assigned ? "Reassign risk" : "Assign risk"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -104,6 +104,11 @@ export function ApiErrorModal() {
onClose={close} onClose={close}
centered centered
radius="md" 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={ title={
<Group gap="xs"> <Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-red-6)" /> <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 { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service"; import type { TrainComposition } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast"; 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) => { const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) { if (isAxiosError(error)) {
@@ -42,6 +43,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 })); 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. // Only serviceable locomotives standing in the selected yard can be coupled.
const locomotivesQuery = useQuery( const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({ api.locomotives.listFiltered.queryOptions({
@@ -150,12 +154,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
<Select <Select
label="Import train number" label="Import train number"
description="Even — Djibouti → Ethiopia runs" description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002" placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
data={IMPORT_TRAIN_OPTIONS} data={importNumbers.options}
value={importTrainNumber || null} value={importTrainNumber || null}
onChange={(value) => setImportTrainNumber(value ?? "")} onChange={(value) => setImportTrainNumber(value ?? "")}
searchable searchable
clearable clearable
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
/> />
</Group> </Group>
<Select <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 { useMutation } from "@tanstack/react-query";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { exportRunFor } from "@/constants/trainRuns";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { BuiltTrainSummary } from "@/services/trainBuilder.service"; 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 * 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 * import/export run numbers. The import number comes from the admin-managed
* on the detail page. Number collisions come back as a 409 with the owning * dropdown setting (numbers on other trains are disabled; this train's own
* train's code and surface verbatim. * 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 EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
const { toast } = useToast(); const { toast } = useToast();
@@ -25,6 +29,8 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
const [importNo, setImportNo] = useState(""); const [importNo, setImportNo] = useState("");
const [exportNo, setExportNo] = useState(""); const [exportNo, setExportNo] = useState("");
const importNumbers = useImportTrainNumberOptions(train?.importTrainNumber);
useEffect(() => { useEffect(() => {
if (train) { if (train) {
setName(train.trainName ?? ""); setName(train.trainName ?? "");
@@ -86,20 +92,29 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
radius="md" radius="md"
/> />
<Group grow> <Group grow>
<TextInput <Select
label="Import train no." label="Import train no."
placeholder="e.g. 8002" placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
value={importNo} data={importNumbers.options}
onChange={(e) => setImportNo(e.currentTarget.value)} value={importNo || null}
maxLength={20} 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" radius="md"
/> />
<TextInput <TextInput
label="Export train no." label="Export train no."
description="Follows the import run"
placeholder="e.g. 8001" placeholder="e.g. 8001"
value={exportNo} value={exportNo}
onChange={(e) => setExportNo(e.currentTarget.value)} readOnly
maxLength={20} variant="filled"
radius="md" radius="md"
/> />
</Group> </Group>

View File

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

View File

@@ -40,17 +40,21 @@ export function PreviewSummary({
summary?: { summary?: {
totalBookings: number; totalBookings: number;
totalWeightTons: number; totalWeightTons: number;
grossWeightTons?: number;
totalTareTons?: number;
wagonType: string; wagonType: string;
wagonsNeeded: number; wagonsNeeded: number;
totalLengthMeters: number; totalLengthMeters: number;
}; };
}) { }) {
if (!summary) return null; if (!summary) return null;
// GROSS — the axis every train limit is spent against.
const gross = summary.grossWeightTons ?? summary.totalWeightTons;
const stats = [ const stats = [
{ label: "Bookings", value: String(summary.totalBookings) }, { label: "Bookings", value: String(summary.totalBookings) },
{ label: "Wagons", value: String(summary.wagonsNeeded) }, { label: "Wagons", value: String(summary.wagonsNeeded) },
{ label: "Wagon type", value: summary.wagonType }, { 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` }, { label: "Train length", value: `${summary.totalLengthMeters}m` },
]; ];
return ( 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 * 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 * 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 * consist can only pull as hard as its weakest engine. Both sides of this meter
* the consist tare to the used weight when it checks this cap; tare isn't * are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
* available client-side, so this meter compares cargo-only load against pull.
*/ */
function pullCapacity(schedule: TrainScheduleDetail): number { function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet; const set = schedule.trainSet;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,10 +93,14 @@ export const TrainConsistView = ({
} }
}; };
const weightUsed = wagons.reduce( // GROSS: cargo on every allocation + the tare of every wagon in the consist.
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0), // 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, 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); const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
return ( return (

View File

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

View File

@@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({
{bookings.map((booking) => { {bookings.map((booking) => {
const isActive = selectedBookingId === booking.id; 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 fits = booking.canAssign;
const blockReason = booking.blockReason; const blockReason = booking.blockReason;

View File

@@ -36,8 +36,12 @@ export const WagonCard = ({
const hasAllocations = Boolean(allocation); const hasAllocations = Boolean(allocation);
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const weightUsed = allocation?.allocatedWeightTons ?? 0; // GROSS on both sides: loaded cargo + wagon tare, against the wagon's max
const weightMax = wagon.capacityTons ?? 0; // 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 weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
const wagonType = wagon.wagonType?.code || "UNKNOWN"; 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 => * The import run implied by an export run; empty string when unset/unknown.
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? ""; * 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 => * The export run implied by an import run; empty string when unset/unknown.
EXPORT_BY_IMPORT[String(importRun ?? "")] ?? ""; * 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, id: booking.id,
reference: booking.reference, reference: booking.reference,
contractReference: booking.contractReference ?? null, contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
approvalSteps: booking.approvalSteps, approvalSteps: booking.approvalSteps,
customerLabel: booking.isGovernment customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government") ? (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: { defaultOptions: {
queries: { queries: {
retry: 1, retry: 1,
staleTime: 30_000, // staleTime: 30_000,
staleTime:0,
// Data freshness is driven by mutation invalidation (MutationCache above), // Data freshness is driven by mutation invalidation (MutationCache above),
// socket pushes, and explicit polling — not by tab focus. Focus refetch // socket pushes, and explicit polling — not by tab focus. Focus refetch
// just re-fires every mounted query each time the window is refocused. // just re-fires every mounted query each time the window is refocused.

View File

@@ -48,6 +48,7 @@ import {
useBookingList, useBookingList,
useBookingListSummary, useBookingListSummary,
} from "@/hooks/bookings/useBookings"; } from "@/hooks/bookings/useBookings";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking"; import type { BookingListRow } from "@/types/booking";
@@ -308,7 +309,17 @@ export default function BookingRequestsPage() {
return ( return (
<div className="py-1"> <div className="py-1">
{ref ? ( {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> <span className="text-xs text-muted-foreground"></span>
)} )}

View File

@@ -23,6 +23,7 @@ import {
Clock, Clock,
PackageCheck, PackageCheck,
PackagePlus, PackagePlus,
RotateCcw,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -110,6 +111,16 @@ export default function DocumentClearanceDetailPage() {
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user); !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 = const docsPhaseComplete =
clearance?.milestones?.some( clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED", (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
@@ -194,6 +205,19 @@ export default function DocumentClearanceDetailPage() {
> >
Create booking Create booking
</Button> </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 ) : undefined
} }
/> />

View File

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

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import { import {
Alert, Alert,
Badge, Badge,
@@ -52,9 +52,21 @@ import {
export default function ContractClearanceDetailPage() { export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { pathname } = useLocation();
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
const { user } = useAuth(); 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: contract, refetch: refetchContract } = useContractDetail(id);
const { const {
data: clearance, data: clearance,
@@ -153,12 +165,9 @@ export default function ContractClearanceDetailPage() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Clearance not found" title="Clearance not found"
backTo="/dashboard/contracts/clearance" backTo={hubHref}
breadcrumbs={[ breadcrumbs={[
{ { label: hubLabel, href: hubHref },
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: "Not found" }, { label: "Not found" },
]} ]}
/> />
@@ -176,12 +185,9 @@ export default function ContractClearanceDetailPage() {
<Stack gap="lg"> <Stack gap="lg">
<PageHeader <PageHeader
title={reference} title={reference}
backTo="/dashboard/contracts/clearance" backTo={hubHref}
breadcrumbs={[ breadcrumbs={[
{ { label: hubLabel, href: hubHref },
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: reference }, { label: reference },
]} ]}
meta={ meta={

View File

@@ -197,6 +197,29 @@ function DirectionIcon({ direction }: { direction: string }) {
} }
function StatusBadge({ row }: { row: ClearanceRow }) { 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) { if (row.paymentExpired) {
return ( return (
<Tooltip <Tooltip
@@ -727,8 +750,12 @@ export default function ContractClearanceListPage() {
) )
} }
onRebook={(row) => 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( navigate(
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`, `/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
) )
} }
onViewContract={(contractId) => onViewContract={(contractId) =>
@@ -813,6 +840,17 @@ const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow"; if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue"; if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green"; 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"; return "gray";
}; };

View File

@@ -4,11 +4,14 @@ import {
Button, Button,
Card, Card,
Group, Group,
MultiSelect,
Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { import {
AlertTriangle, AlertTriangle,
@@ -17,6 +20,7 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
FileText, FileText,
FilterX,
Inbox, Inbox,
LayoutList, LayoutList,
RefreshCw, RefreshCw,
@@ -36,7 +40,10 @@ import {
} from "@/components/contracts/ContractStatusTabs"; } from "@/components/contracts/ContractStatusTabs";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; 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 { import {
getStaffRowAction, getStaffRowAction,
toContractListRow, toContractListRow,
@@ -61,6 +68,63 @@ function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
return match.statuses.join(","); 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 { function formatDate(value: string | null | undefined): string {
if (!value) return "—"; if (!value) return "—";
const d = new Date(value); const d = new Date(value);
@@ -79,32 +143,86 @@ export default function ContractRequestsPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300); const [debouncedQuery] = useDebouncedValue(query, 300);
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all"); 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 tabStatuses = getStatusesForTab(activeTab);
const statusOptions = useMemo(
() => getStatusOptionsForTab(activeTab),
[activeTab],
);
const resetPage = useCallback(() => { const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, 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, page: pagination.pageIndex + 1,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
sortBy: "createdAt", sortBy,
sortOrder: "DESC", sortOrder,
tab: activeTab, tab: activeTab,
// Server-side free-text search (contract reference, customer name). // Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}), ...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(tabStatuses ? { statuses: tabStatuses } : {}), // Explicit status picks narrow within the tab; otherwise the tab's
}), // status group applies.
[ ...(statusFilter.length
pagination.pageIndex, ? { statuses: statusFilter.join(",") }
pagination.pageSize, : tabStatuses
activeTab, ? { statuses: tabStatuses }
tabStatuses, : {}),
debouncedQuery, ...(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 } = const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter); useContractList(filter);
@@ -341,6 +459,8 @@ export default function ContractRequestsPage() {
active={activeTab} active={activeTab}
onChange={(tab) => { onChange={(tab) => {
setActiveTab(tab); setActiveTab(tab);
// Status picks belong to the previous tab's option set — reset.
setStatusFilter([]);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
counts={tabCounts} counts={tabCounts}
@@ -349,38 +469,159 @@ export default function ContractRequestsPage() {
<Card p={0}> <Card p={0}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap"> <Stack gap="sm">
<TextInput <Group justify="space-between" gap="md" wrap="wrap">
placeholder="Search reference or customer…" <TextInput
leftSection={<Search size={18} />} placeholder="Search reference or customer…"
value={query} leftSection={<Search size={18} />}
onChange={(e) => { value={query}
setQuery(e.target.value); onChange={(e) => {
resetPage(); setQuery(e.target.value);
}} resetPage();
rightSection={ }}
query && ( rightSection={
<ActionIcon query && (
size="sm" <ActionIcon
color="gray" size="sm"
radius="md" color="gray"
variant="transparent" radius="md"
onClick={() => { variant="transparent"
setQuery(""); onClick={() => {
resetPage(); setQuery("");
}} resetPage();
> }}
<X size={16} /> >
</ActionIcon> <X size={16} />
) </ActionIcon>
} )
style={{ flex: 1, minWidth: "200px" }} }
radius="lg" style={{ flex: 1, minWidth: "200px" }}
/> radius="lg"
<Text size="sm" c="dimmed"> />
{total} record{total !== 1 ? "s" : ""} <Select
</Text> data={SORT_OPTIONS}
</Group> 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> </Box>
{showEmpty ? ( {showEmpty ? (

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { import {
Alert, Alert,
Badge, Badge,
@@ -18,7 +18,6 @@ import {
AlertCircle, AlertCircle,
ClipboardList, ClipboardList,
FileText, FileText,
PackagePlus,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -61,9 +60,12 @@ type GlClearanceDetail =
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> { async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
try { 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([ const [clearance, contract] = await Promise.all([
contractsService.getClearance(id), contractsService.getClearance(id, { suppressErrorModal: true }),
contractsService.getById(id), contractsService.getById(id, { suppressErrorModal: true }),
]); ]);
return { return {
kind: "contract", 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. */ /** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
export default function GlClearanceDetailPage() { export default function GlClearanceDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null); const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
@@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() {
{hasRo ? "Replace RO" : "Upload RO"} {hasRo ? "Replace RO" : "Upload RO"}
</Button> </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> </Group>
} }
/> />

View File

@@ -106,6 +106,18 @@ function statusColor(status: string): string {
case "ACTIVE_SHIPMENT_IN_PROGRESS": case "ACTIVE_SHIPMENT_IN_PROGRESS":
case "IN_TRANSIT": case "IN_TRANSIT":
return "teal"; 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: default:
return "gray"; return "gray";
} }

View File

@@ -18,6 +18,8 @@ import {
CalendarClock, CalendarClock,
MapPin, MapPin,
MoreHorizontal, MoreHorizontal,
Power,
PowerOff,
Replace, Replace,
Ruler, Ruler,
Trash2, Trash2,
@@ -70,6 +72,7 @@ export default function TrainBuilderDetailPage() {
const [locoModalOpen, setLocoModalOpen] = useState(false); const [locoModalOpen, setLocoModalOpen] = useState(false);
const [yardModalOpen, setYardModalOpen] = useState(false); const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false); const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const compositionQuery = useQuery( const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), 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 reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const disband = useMutation(api.trainBuilder.disband.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 composition = compositionQuery.data;
const busy = const busy =
@@ -172,6 +177,27 @@ export default function TrainBuilderDetailPage() {
> >
Change yard Change yard
</Menu.Item> </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 <Menu.Item
color="red" color="red"
leftSection={<Trash2 size={15} />} leftSection={<Trash2 size={15} />}
@@ -356,6 +382,39 @@ export default function TrainBuilderDetailPage() {
onClose={() => setYardModalOpen(false)} 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 <Modal
opened={disbandOpen} opened={disbandOpen}
onClose={() => setDisbandOpen(false)} onClose={() => setDisbandOpen(false)}

View File

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

View File

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

View File

@@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) =>
const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT"; const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT";
const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED"; 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 (!yard) return <Text size="sm"></Text>;
if (has) return <Text size="sm">{yard}</Text>; if (has) return <Text size="sm">{yard}</Text>;
return ( return (
<Tooltip <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 withArrow
multiline multiline
w={240} w={260}
> >
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<AlertTriangle size={13} color="var(--mantine-color-red-6)" /> <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>{r.customer ?? "—"}</Table.Td>
<Table.Td> <Table.Td>
<Group gap={6} wrap="nowrap"> <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) && ( {atOrigin(r) && isWaiting(r) && (
<Badge size="xs" color="edr-green" variant="light"> <Badge size="xs" color="edr-green" variant="light">
train here train here
@@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={6} wrap="nowrap"> <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) && ( {atDestination(r) && isRiding(r) && (
<Badge size="xs" color="edr-green" variant="light"> <Badge size="xs" color="edr-green" variant="light">
train here train here
@@ -215,7 +228,7 @@ export default function IntercityPage() {
<Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} /> <Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} />
<Stat <Stat
icon={<AlertTriangle size={18} />} icon={<AlertTriangle size={18} />}
label="No facility" label="Cannot handle"
value={blocked.length} value={blocked.length}
color={blocked.length > 0 ? "red" : undefined} 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`} title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`}
mb="md" mb="md"
> >
Their origin or destination yard has no load/unload facility. Mark the yard as Their origin or destination yard cannot handle that cargo no facility, or no
a facility in Configuration Yards, or the cargo can never be worked there. equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at
any facility. Adjust the yard in Configuration Yards.
</Alert> </Alert>
)} )}

View File

@@ -193,6 +193,7 @@ import {
type ScheduleConsist, type ScheduleConsist,
type TrainComposition, type TrainComposition,
type UpdateTrainDetailsPayload, type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
} from "./trainBuilder.service"; } from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service"; import { trainSchedulingService } from "./trainScheduling.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service"; import { wagonTypesService, type WagonType } from "./wagon-types.service";
@@ -1825,6 +1826,14 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id), ({ 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>( build: endpoint<BuildTrainPayload, TrainComposition>(
"train-builder", "train-builder",
"build", "build",
@@ -1902,6 +1911,22 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS, () => 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>( disband: endpoint<string, void>(
"train-builder", "train-builder",
"disband", "disband",

View File

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

View File

@@ -9,7 +9,8 @@ export type BuiltTrainStatus =
| "SCHEDULED" | "SCHEDULED"
| "IN_SERVICE" | "IN_SERVICE"
| "UNDER_MAINTENANCE" | "UNDER_MAINTENANCE"
| "OUT_OF_SERVICE"; | "OUT_OF_SERVICE"
| "DEACTIVATED";
export interface YardRefLite { export interface YardRefLite {
id: string; id: string;
@@ -140,6 +141,12 @@ export interface BuildTrainPayload {
notes?: string; 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. */ /** Edit a built train's display identity; omitted fields keep their value. */
export interface UpdateTrainDetailsPayload { export interface UpdateTrainDetailsPayload {
/** Empty string clears the name. */ /** Empty string clears the name. */
@@ -261,6 +268,8 @@ export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) => list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`), apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`), 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), build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) => setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }), apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
@@ -279,6 +288,11 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`), apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
reorderWagons: (id: string, wagonIds: string[]) => reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }), 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}`), disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
/** Built trains schedulable on a route (train-scheduling picker). */ /** Built trains schedulable on a route (train-scheduling picker). */
availableTrains: (routeId: string) => availableTrains: (routeId: string) =>

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
bookingIsSignable, bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton"; } from "@/pages/bookings/contract/ContractSignButton";
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton"; import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
import { ContractReferenceLink } from "@/pages/bookings/booking-display";
interface BookingRowProps { interface BookingRowProps {
booking: any; booking: any;
@@ -27,9 +28,15 @@ export const BookingRow = memo(function BookingRow({
const AIcon = cfg.action.icon; const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind]; const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal // 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 = 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 // Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row. // via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking); const hasInlineAction = bookingHasInlineAction(booking);
@@ -73,6 +80,7 @@ export const BookingRow = memo(function BookingRow({
<Text fz={15} fw={700} c="edr-text" truncate> <Text fz={15} fw={700} c="edr-text" truncate>
{booking.reference} {booking.reference}
</Text> </Text>
<ContractReferenceLink booking={booking} />
<Text fz={12} c="edr-muted" truncate> <Text fz={12} c="edr-muted" truncate>
{commodity} · {origin} {dest} {commodity} · {origin} {dest}
</Text> </Text>

View File

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

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