mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -277,6 +277,9 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// await this.demoUsersSeeder.run();
|
||||
// await this.freightStaffUsersSeeder.run();
|
||||
// await this.pricingDataSeeder.run();
|
||||
// IndodeFacilitySeeder keys its warehouses on INDODE_OPEN / INDODE_CLOSED, so
|
||||
// it will not recognise a hand-created Indode warehouse and will seed a second
|
||||
// one alongside it. Only enable it against an Indode that has no warehouse.
|
||||
// await this.indodeFacilitySeeder.run();
|
||||
// await this.batch14TestDataSeeder.run();
|
||||
// await this.batch5TestDataSeeder.run();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -305,6 +305,10 @@ export class BookingPricingService {
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
|
||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
||||
returnQuantity: Number(bc.returnQuantity ?? 0),
|
||||
},
|
||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||
quantity: qty,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { In, Not } from "typeorm";
|
||||
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
@@ -34,8 +33,6 @@ import {
|
||||
BookingReferenceYardDto,
|
||||
} from "./dto/booking-reference-data.dto";
|
||||
|
||||
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
|
||||
|
||||
export function buildCargoTypeTree(
|
||||
rows: CargoType[],
|
||||
): BookingReferenceCargoTypeGroupDto[] {
|
||||
@@ -134,10 +131,7 @@ export class BookingReferenceDataService {
|
||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||
await Promise.all([
|
||||
this.yardsRepository.findAll({
|
||||
where: {
|
||||
isActive: true,
|
||||
code: Not(In([...LEGACY_YARD_CODES])),
|
||||
},
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.containerTypesRepository.findAll({
|
||||
|
||||
@@ -42,6 +42,7 @@ export class BookingTransitionService {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
@Inject(forwardRef(() => BookingContractService))
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@@ -1049,7 +1050,25 @@ export class BookingTransitionService {
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
if (isExportTrain) {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
// With export split ON the booking no longer has to ride ONE train whole:
|
||||
// the largest fitting part is offered and the leftover rebooks on the next
|
||||
// train. So the day is only unbookable when NO export train that day has
|
||||
// any room at all — reject on the day total, not on a single-train fit.
|
||||
// With the flag off this stays the strict whole-booking gate.
|
||||
if (process.env.FREIGHT_EXPORT_SPLIT === "true") {
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
scheduledBooking,
|
||||
eatDay(date),
|
||||
"EXPORT",
|
||||
);
|
||||
if (!fitting.length) {
|
||||
throw new ConflictException(
|
||||
"No export train on this day has space left — pick another shipment day.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
|
||||
@@ -1287,6 +1287,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Same as {@link findAllBySchedule} but for a page of schedules at once —
|
||||
* one query instead of one per schedule (batch monitoring board). */
|
||||
findAllBySchedules(scheduleIds: string[]): Promise<Booking[]> {
|
||||
if (!scheduleIds.length) return Promise.resolve([]);
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
.where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds })
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
@@ -1342,6 +1359,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
if (!bookingIds.length) return Promise.resolve([]);
|
||||
return this.bookingRepo(manager).find({
|
||||
where: { id: In(bookingIds) },
|
||||
// Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins
|
||||
// multiply rows badly in a single join (hot path for every allocation
|
||||
// preview / assignment validation).
|
||||
relationLoadStrategy: 'query',
|
||||
relations: {
|
||||
company: true,
|
||||
originYard: true,
|
||||
|
||||
@@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity {
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
/** This container ships back empty after unloading (equipment return). */
|
||||
@Column({ name: 'is_return', type: 'boolean', default: false })
|
||||
isReturn!: boolean;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
@@ -85,6 +88,8 @@ export interface BookingClearanceView {
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
@@ -282,6 +287,12 @@ export class BookingClearanceService {
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
// Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are
|
||||
// the current one; this is the trail behind it.
|
||||
riskHistory:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||
: [],
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
|
||||
@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
|
||||
expect(saved.status).toBe('COMPLETED');
|
||||
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
||||
});
|
||||
|
||||
/**
|
||||
* The level is customer-visible and stays correctable until duty is advised,
|
||||
* so a changed level must leave a trail rather than overwrite the last one.
|
||||
*/
|
||||
describe('risk history', () => {
|
||||
it('records the first assignment with no previous level', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.');
|
||||
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||
level: 'RED',
|
||||
assignedByUserId: 'user-1',
|
||||
assignedBy: 'Abebe K.',
|
||||
note: 'initial rating',
|
||||
});
|
||||
expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel');
|
||||
});
|
||||
|
||||
it('keeps the earlier decision when the level is reassigned', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.');
|
||||
const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.');
|
||||
|
||||
expect(saved.metadata?.riskLevel).toBe('GREEN');
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(2);
|
||||
// The original RED decision survives, with who made it.
|
||||
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||
level: 'RED',
|
||||
assignedBy: 'Abebe K.',
|
||||
});
|
||||
expect(saved.metadata?.riskHistory?.[1]).toMatchObject({
|
||||
level: 'GREEN',
|
||||
previousLevel: 'RED',
|
||||
assignedByUserId: 'user-2',
|
||||
assignedBy: 'Sara M.',
|
||||
note: 'downgraded',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the whole chain across several reassignments, oldest first', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'GREEN');
|
||||
await service.assignRisk('b-1', 'YELLOW');
|
||||
const saved = await service.assignRisk('b-1', 'RED');
|
||||
|
||||
expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([
|
||||
'GREEN',
|
||||
'YELLOW',
|
||||
'RED',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not record a repeat of the level already assigned', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'GREEN');
|
||||
const saved = await service.assignRisk('b-1', 'GREEN');
|
||||
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('always leaves riskLevel equal to the last history entry', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'RED');
|
||||
const saved = await service.assignRisk('b-1', 'YELLOW');
|
||||
|
||||
const history = saved.metadata?.riskHistory ?? [];
|
||||
expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,15 +210,50 @@ export class ClearanceMilestoneService {
|
||||
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
|
||||
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
||||
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
||||
*
|
||||
* The level stays correctable until duty is advised off it, so each assignment
|
||||
* is appended to `riskHistory` instead of silently replacing the last one — a
|
||||
* customer-visible level that changes needs a trail of who changed it and when.
|
||||
*/
|
||||
async assignRisk(
|
||||
bookingId: string,
|
||||
riskLevel: CustomsRiskLevel,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
actor?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
await this.assertT1Closed(bookingId);
|
||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
||||
|
||||
const existing = await this.repo.findOne({
|
||||
where: { bookingId, milestoneCode: 'RISK_ASSIGNED' },
|
||||
});
|
||||
const previousLevel = existing?.metadata?.riskLevel;
|
||||
const history = existing?.metadata?.riskHistory ?? [];
|
||||
|
||||
// A repeat of the level already assigned is not a decision — recording it
|
||||
// would pad the trail with entries that changed nothing.
|
||||
const entries =
|
||||
previousLevel === riskLevel
|
||||
? history
|
||||
: [
|
||||
...history,
|
||||
{
|
||||
level: riskLevel,
|
||||
...(previousLevel ? { previousLevel } : {}),
|
||||
assignedAt: new Date().toISOString(),
|
||||
assignedByUserId: userId ?? null,
|
||||
assignedBy: actor ?? null,
|
||||
note: note ?? null,
|
||||
},
|
||||
];
|
||||
|
||||
return this.completeWithMetadata(
|
||||
bookingId,
|
||||
'RISK_ASSIGNED',
|
||||
{ riskLevel, riskHistory: entries },
|
||||
userId,
|
||||
note,
|
||||
);
|
||||
}
|
||||
|
||||
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
|
||||
|
||||
@@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
@@ -39,7 +40,10 @@ import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
import {
|
||||
CreateBookingContainerLineDto,
|
||||
CreateBookingUnderContractDto,
|
||||
} from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||
@@ -54,6 +58,18 @@ export interface CreateBookingUnderContractResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Outstanding split remainder of a contract: what was booked in the first split
|
||||
* booking's pre-split snapshot MINUS everything currently booked. Container
|
||||
* contracts report per size; bulk reports one tonnage figure. `null` when the
|
||||
* contract has no live split chain. Consumed by the remainder-placement engine
|
||||
* to size the auto-created remainder booking.
|
||||
*/
|
||||
export type SplitOutstanding = {
|
||||
bySize: Map<string, { total: number; outstanding: number }>;
|
||||
bulk: { total: number; outstanding: number } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single create path for shipment bookings under a contract.
|
||||
*
|
||||
@@ -270,8 +286,8 @@ export class ContractBookingService {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
@@ -580,11 +596,40 @@ export class ContractBookingService {
|
||||
if (!booking || booking.contractId !== contract.id) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
|
||||
}
|
||||
if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) {
|
||||
if (
|
||||
!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes(
|
||||
booking.status,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Clearance must be finalized before the booking can be completed.',
|
||||
);
|
||||
}
|
||||
// An unpaid booking that expired at train dispatch keeps its finished
|
||||
// per-booking clearance — GL rebooks it onto a new shipment day instead of
|
||||
// forcing the customer through a new shipment request + clearance fee.
|
||||
if (booking.status === 'EXPIRED') {
|
||||
// Only a booking that completed once (it has a price, so its clearance
|
||||
// finished and cargo is persisted) can be rebooked after expiry.
|
||||
if (!(Number(booking.totalAmount) > 0)) {
|
||||
throw new BadRequestException(
|
||||
'Only a previously completed booking can be rebooked after it expires.',
|
||||
);
|
||||
}
|
||||
// Expiry released the booking's contract-capacity hold; if the payload
|
||||
// re-states the cargo, make sure the released share is still free.
|
||||
if (dto.containers?.length || dto.bulkLines?.length) {
|
||||
await this.assertWithinQuantityCap(contract, dto);
|
||||
}
|
||||
// Drop the departed train's link and fall into the day-only resubmit
|
||||
// path below — same machinery as OPERATION_CHANGES_REQUESTED.
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
trainScheduleId: null,
|
||||
} as never);
|
||||
booking.status = 'OPERATION_CHANGES_REQUESTED';
|
||||
booking.trainScheduleId = null;
|
||||
}
|
||||
// Path B: only GL Ethiopia completes a customs instance — the customer
|
||||
// never enters shipment data on a customs contract.
|
||||
if (contract.customsClearingEnabled) {
|
||||
@@ -966,9 +1011,11 @@ export class ContractBookingService {
|
||||
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
|
||||
* contract has no live split booking.
|
||||
*/
|
||||
private async splitOutstanding(
|
||||
contract: Contract,
|
||||
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
|
||||
/**
|
||||
* Public: the remainder-placement engine reads this to size the auto-created
|
||||
* remainder booking. Returns `null` when there is no live split chain.
|
||||
*/
|
||||
async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
|
||||
const first = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
@@ -1015,6 +1062,25 @@ export class ContractBookingService {
|
||||
const probe = await this.buildExportProbe(contract, route, dto, yards);
|
||||
const report = await this.bookingBatchService.exportSpaceReport(probe);
|
||||
if (report.scheduleId) return;
|
||||
|
||||
// With export split ON a booking no longer has to ride ONE train whole: the
|
||||
// largest fitting part is offered and the leftover is rebooked on the next
|
||||
// train. Rejecting on the single-train fit here would block exactly the
|
||||
// bookings the split exists to serve — including the auto-created remainder,
|
||||
// which by definition did not fit the train it was split off. Fall back to
|
||||
// the day total: unbookable only when NO export train that day has room.
|
||||
if (process.env.FREIGHT_EXPORT_SPLIT === 'true') {
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
probe,
|
||||
eatDay(new Date(dto.scheduledDate)),
|
||||
'EXPORT',
|
||||
);
|
||||
if (fitting.length > 0) return;
|
||||
throw new BadRequestException(
|
||||
'No export train on this day has space left — pick another shipment day.',
|
||||
);
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
report.fullMessage ?? 'Not enough train space for this day.',
|
||||
);
|
||||
@@ -1417,6 +1483,53 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line handling counts. Each physical container carries its own hazardous
|
||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||
* many units opted in. Forms that predate per-unit switches send line-level
|
||||
* counts and no unit flags — those are honoured as-is.
|
||||
*/
|
||||
private handlingCounts(line: CreateBookingContainerLineDto): {
|
||||
hazardousQuantity: number;
|
||||
reeferQuantity: number;
|
||||
returnQuantity: number;
|
||||
} {
|
||||
const units = line.units ?? [];
|
||||
const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn);
|
||||
if (!flagged) {
|
||||
return {
|
||||
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(line.reeferQuantity ?? 0),
|
||||
returnQuantity: Number(line.returnQuantity ?? 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
hazardousQuantity: units.filter((u) => u.isHazardous).length,
|
||||
reeferQuantity: units.filter((u) => u.isReefer).length,
|
||||
returnQuantity: units.filter((u) => u.isReturn).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-level hazardous / reefer flags. The CONTRACT gates the service; the
|
||||
* per-container opt-ins decide whether THIS shipment actually uses it. A
|
||||
* container contract that allows hazardous but a booking where nobody ticked
|
||||
* the switch is not a hazardous booking, and must not fire the surcharge.
|
||||
* Bulk keeps the contract flag — it has its own bulk*Quantity fields.
|
||||
*/
|
||||
private resolveShipmentHandlingFlag(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
field: 'hazardousQuantity' | 'reeferQuantity',
|
||||
): boolean {
|
||||
const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer;
|
||||
if (!gated) return false;
|
||||
if (contract.freightType !== 'CONTAINER') return true;
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) return Boolean(gated);
|
||||
return lines.some((l) => this.handlingCounts(l)[field] > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the booking's equipment return from the per-line return quantities
|
||||
* (container freight). The CONTRACT gates the service — like hazardous:
|
||||
@@ -1436,7 +1549,7 @@ export class ContractBookingService {
|
||||
|
||||
const lines = dto.containers ?? [];
|
||||
for (const line of lines) {
|
||||
const qty = Number(line.returnQuantity ?? 0);
|
||||
const qty = this.handlingCounts(line).returnQuantity;
|
||||
if (qty === 0) continue;
|
||||
if (contract.equipmentReturn !== 'WITH_RETURN') {
|
||||
throw new BadRequestException(
|
||||
@@ -1452,7 +1565,7 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
if (contract.equipmentReturn === 'WITH_RETURN') {
|
||||
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
|
||||
const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0);
|
||||
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
|
||||
}
|
||||
return legacy;
|
||||
@@ -1490,9 +1603,10 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
const counts = this.handlingCounts(line);
|
||||
const containerType = await this.resolveContainerTypeForSize(
|
||||
line.containerSize,
|
||||
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||
contract.isReefer || counts.reeferQuantity > 0,
|
||||
);
|
||||
|
||||
const vgmPerUnit = line.units.length
|
||||
@@ -1506,12 +1620,10 @@ export class ContractBookingService {
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
hazardousQuantity: counts.hazardousQuantity,
|
||||
reeferQuantity: counts.reeferQuantity,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
: 0,
|
||||
contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
|
||||
@@ -1530,6 +1642,8 @@ export class ContractBookingService {
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
isReturn:
|
||||
contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
@@ -1630,12 +1744,14 @@ export class ContractBookingService {
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
isGovernment: contract.isGovernment,
|
||||
shippingLineId: null,
|
||||
contractRouteId: route?.id ?? null,
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
@@ -1644,11 +1760,11 @@ export class ContractBookingService {
|
||||
containerTypeId: ct.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
|
||||
reeferQuantity: this.handlingCounts(line).reeferQuantity,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
? this.handlingCounts(line).returnQuantity
|
||||
: 0,
|
||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||
totalVgmTons,
|
||||
|
||||
@@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
@@ -102,6 +105,8 @@ export interface ContractClearanceView {
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
@@ -365,6 +370,11 @@ export class ContractClearanceService {
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
// Every risk decision, oldest first — see booking-clearance.service.
|
||||
riskHistory:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||
: [],
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
@@ -968,13 +969,16 @@ export class ContractsController {
|
||||
assignRisk(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: AssignRiskDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.milestoneService.assignRisk(
|
||||
bookingId,
|
||||
dto.riskLevel,
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
// Risk history is read by people, so resolve the name now — the id alone
|
||||
// would render as a UUID in the trail.
|
||||
actorLabel(user),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,15 @@ export class CreateContainerUnitDto {
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'This container ships back empty (equipment return).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReturn?: boolean;
|
||||
}
|
||||
|
||||
export class CreateBookingContainerLineDto {
|
||||
|
||||
@@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
||||
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
|
||||
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* One customs risk decision. Risk stays correctable until duty is advised off
|
||||
* it, and the level is customer-visible, so every assignment is kept rather than
|
||||
* overwritten — a disputed level needs to show what was set, by whom, and when.
|
||||
*/
|
||||
export interface RiskAssignmentRecord {
|
||||
level: CustomsRiskLevel;
|
||||
/** The level this replaced; absent on the first assignment. */
|
||||
previousLevel?: CustomsRiskLevel;
|
||||
assignedAt: string;
|
||||
assignedByUserId?: string | null;
|
||||
/** Display name resolved at assignment time, so the trail never shows a UUID. */
|
||||
assignedBy?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured payload some milestones carry beyond a plain note (doc §11.3):
|
||||
* - RISK_ASSIGNED → `riskLevel`
|
||||
* - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment)
|
||||
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
|
||||
* Stored on the milestone so the timeline can render the value inline.
|
||||
*/
|
||||
export interface MilestoneMetadata {
|
||||
riskLevel?: CustomsRiskLevel;
|
||||
/**
|
||||
* Append-only, oldest first. `riskLevel` is the current value and always
|
||||
* equals the last entry's `level`.
|
||||
*/
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
dutyAmount?: number;
|
||||
dutyCurrency?: string;
|
||||
declarationSerial?: string;
|
||||
|
||||
@@ -256,6 +256,11 @@ export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Terminal contracts stay on the list — the clearance hub is GL's history of
|
||||
// everything that passed through, not just the live work queue.
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
'REJECTED',
|
||||
] as const;
|
||||
|
||||
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
|
||||
@@ -275,12 +280,22 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
// Payment phase — the booking is selected/awaiting the customer's payment.
|
||||
'SELECTED_FOR_BATCH',
|
||||
'PNR_GENERATED',
|
||||
'AWAITING_PAYMENT',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'PAID',
|
||||
'COMPLETED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Terminal bookings stay on the list — EXPIRED especially: GL rebooks it
|
||||
// from here, and the hub doubles as clearance history.
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
'REJECTED',
|
||||
] as const;
|
||||
|
||||
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
|
||||
|
||||
@@ -74,9 +74,14 @@ export class CreateRateDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
rateValue!: number;
|
||||
|
||||
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
||||
@ApiPropertyOptional({
|
||||
enum: RATE_UNITS,
|
||||
description:
|
||||
'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...RATE_UNITS])
|
||||
rateUnit!: string;
|
||||
rateUnit?: string;
|
||||
}
|
||||
|
||||
export class SubmitRateForApprovalDto {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,12 @@ export function deriveRateType(input: {
|
||||
return 'HAZARD_SURCHARGE';
|
||||
case 'REEFER':
|
||||
return 'REEFER_SURCHARGE';
|
||||
// Empty-container return service. Contract pricing looks this rateType up
|
||||
// by name, so without the mapping a WITH_RETURN rate fell through to the
|
||||
// base-freight branch and was stored as CANCELLATION_FEE — invisible to
|
||||
// the contract, and mislabelled on the booking's snapshot.
|
||||
case 'WITH_RETURN':
|
||||
return 'RETURN_SURCHARGE';
|
||||
case 'OVERWEIGHT':
|
||||
return 'OVERWEIGHT_PER_TON';
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
|
||||
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
||||
hasWarehouse!: boolean;
|
||||
|
||||
/**
|
||||
* Containers need a reach stacker or gantry, so only the equipped facilities
|
||||
* (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled
|
||||
* everywhere.
|
||||
*/
|
||||
@Column({ name: 'handles_container', type: 'boolean', default: true })
|
||||
handlesContainer!: boolean;
|
||||
|
||||
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
|
||||
handlesBulk!: boolean;
|
||||
|
||||
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
||||
equipmentNotes?: string | null;
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
@Index(['country'])
|
||||
@Index(['isActive'])
|
||||
export class Yard extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||
// 40 leaves room for the `@<epoch-ms>` suffix soft-delete appends to free the code.
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||
|
||||
@@ -42,6 +42,14 @@ export interface BookingContainerEvalInput {
|
||||
isReefer?: boolean;
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | null;
|
||||
/**
|
||||
* How many individual containers on this line opted into each handling
|
||||
* service. PER_CONTAINER surcharges bill these counts, not the line
|
||||
* quantity — 20 containers with 10 hazardous bill hazard on 10.
|
||||
*/
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
returnQuantity?: number;
|
||||
}
|
||||
|
||||
export interface BookingEvaluationInput {
|
||||
@@ -270,6 +278,27 @@ export class RuleEngineService {
|
||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||
0,
|
||||
);
|
||||
/**
|
||||
* Containers that opted into this trigger's handling service, summed
|
||||
* across lines. null when the trigger isn't per-container handling (or
|
||||
* no line carries a count) so the caller falls back to the full count.
|
||||
*/
|
||||
const optedInCount = (trigger: string | null): number | null => {
|
||||
const field =
|
||||
trigger === 'HAZARDOUS'
|
||||
? 'hazardousQuantity'
|
||||
: trigger === 'REEFER'
|
||||
? 'reeferQuantity'
|
||||
: trigger === 'WITH_RETURN'
|
||||
? 'returnQuantity'
|
||||
: null;
|
||||
if (!field) return null;
|
||||
const total = input.containers.reduce(
|
||||
(sum, c) => sum + Number(c[field] ?? 0),
|
||||
0,
|
||||
);
|
||||
return total > 0 ? total : null;
|
||||
};
|
||||
|
||||
let triggerValue: number | null = null;
|
||||
let calculatedAmount: number;
|
||||
@@ -285,7 +314,11 @@ export class RuleEngineService {
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'PER_CONTAINER':
|
||||
triggerValue = containerCount;
|
||||
// Handling surcharges bill only the containers that opted in, not the
|
||||
// whole line — 20 containers with 10 hazardous bill hazard on 10.
|
||||
// Legacy bookings carry no per-container counts (all 0) while their
|
||||
// booking-level flag is set, so fall back to the full count there.
|
||||
triggerValue = optedInCount(rate.trigger) ?? containerCount;
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'PER_WAGON':
|
||||
|
||||
@@ -68,15 +68,21 @@ export class RatesService {
|
||||
private resolveRateUnit(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
requestedUnit: Rate['rateUnit'],
|
||||
requestedUnit: Rate['rateUnit'] | undefined,
|
||||
): Rate['rateUnit'] {
|
||||
// Overweight is per-ton, full stop.
|
||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||
// for it and omits rateUnit from the payload entirely.
|
||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger });
|
||||
if (!requestedUnit) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
|
||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return requestedUnit;
|
||||
@@ -272,7 +278,11 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
||||
const rateUnit = this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
|
||||
@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
|
||||
hasFacility: boolean;
|
||||
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
|
||||
hasWarehouse: boolean;
|
||||
/** Containers need a reach stacker/gantry — not every facility has one. */
|
||||
handlesContainer: boolean;
|
||||
handlesBulk: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which yards can handle cargo, and how.
|
||||
* Which yards can handle cargo, and what kind.
|
||||
*
|
||||
* A yard is a load/unload point when `yards.has_facility` is set; the matching
|
||||
* `yard_facilities` record says whether it also stores cargo. Facilities without a
|
||||
* warehouse move cargo on and off the train and nothing more — no storage, no
|
||||
* demurrage. This is the single resolver the journey and handling flows use, so
|
||||
* they can't drift on what a facility is.
|
||||
* `yard_facilities` record says what it can actually do — whether it stores cargo
|
||||
* (storage/demurrage), and which freight types its equipment can lift. Containers
|
||||
* need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
|
||||
* bulk is handled at all five.
|
||||
*
|
||||
* This is the single resolver the journey and handling flows use, so they can't
|
||||
* drift on what a facility is or what it can lift.
|
||||
*/
|
||||
@Injectable()
|
||||
export class YardFacilitiesService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
||||
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
||||
const [row]: Array<{
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
||||
WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||
[yardId],
|
||||
);
|
||||
if (!row) return null;
|
||||
private readonly SELECT = `
|
||||
SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse",
|
||||
f.handles_container AS "handlesContainer",
|
||||
f.handles_bulk AS "handlesBulk"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
|
||||
|
||||
private toInfo(row: {
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
handlesContainer: boolean | null;
|
||||
handlesBulk: boolean | null;
|
||||
}): YardFacilityInfo {
|
||||
// No facility record means no capability, whatever the flag says.
|
||||
const hasFacility = Boolean(row.hasFacility);
|
||||
return {
|
||||
yardId: row.yardId,
|
||||
yardCode: row.yardCode,
|
||||
yardLabel: row.yardLabel,
|
||||
hasFacility: Boolean(row.hasFacility),
|
||||
// No facility record means no warehouse, whatever the flag says.
|
||||
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
|
||||
hasFacility,
|
||||
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
||||
handlesContainer: hasFacility && Boolean(row.handlesContainer),
|
||||
handlesBulk: hasFacility && Boolean(row.handlesBulk),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
||||
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||
[yardId],
|
||||
);
|
||||
return row ? this.toInfo(row) : null;
|
||||
}
|
||||
|
||||
/** Every yard that can load/unload, for pickers and the intercity queues. */
|
||||
async listFacilityYards(): Promise<YardFacilityInfo[]> {
|
||||
const rows: Array<{
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
||||
WHERE y.deleted_at IS NULL
|
||||
AND y.is_active = true
|
||||
AND y.has_facility = true
|
||||
const rows = await this.dataSource.query(
|
||||
`${this.SELECT}
|
||||
WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
|
||||
ORDER BY y.display_order ASC, y.label ASC`,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
yardId: r.yardId,
|
||||
yardCode: r.yardCode,
|
||||
yardLabel: r.yardLabel,
|
||||
hasFacility: true,
|
||||
hasWarehouse: Boolean(r.hasWarehouse),
|
||||
}));
|
||||
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
||||
* so callers can't get it subtly wrong.
|
||||
*/
|
||||
canHandleFreight(
|
||||
facility: YardFacilityInfo | null,
|
||||
freightType: string | null | undefined,
|
||||
): boolean {
|
||||
if (!facility?.hasFacility) return false;
|
||||
return String(freightType).toUpperCase() === 'CONTAINER'
|
||||
? facility.handlesContainer
|
||||
: facility.handlesBulk;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class YardsService {
|
||||
|
||||
/** Create a yard. */
|
||||
async create(dto: CreateYardDto): Promise<Yard> {
|
||||
const code = generateCode(dto.label);
|
||||
const code = generateCode(dto.label).slice(0, 40);
|
||||
const existing = await this.repository.findByCode(code);
|
||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||
|
||||
@@ -58,9 +58,19 @@ export class YardsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Soft-delete a yard. */
|
||||
/**
|
||||
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
||||
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
|
||||
* same name can be created later without tripping UQ_yards_code, which spans
|
||||
* soft-deleted rows too.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
const yard = await this.findById(id);
|
||||
const suffix = `@${Date.now()}`;
|
||||
await this.repository.update(id, {
|
||||
code: `${yard.code.slice(0, 40 - suffix.length)}${suffix}`,
|
||||
label: `${yard.label.slice(0, 100 - suffix.length)}${suffix}`,
|
||||
});
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
|
||||
return this.repo(manager).findOne({
|
||||
where: { id },
|
||||
// One SELECT per relation instead of a single monster join — the nested
|
||||
// wagon×allocation×booking×container branches multiply rows catastrophically
|
||||
// when joined (measured ~925ms vs ~84ms on a 21-wagon schedule).
|
||||
relationLoadStrategy: 'query',
|
||||
relations: {
|
||||
// Yards carry the route's display name; without them formatRouteLabel
|
||||
// degrades to the literal "Origin → Destination". Milestones (with
|
||||
|
||||
@@ -958,20 +958,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
// assertion below that says "not full" proves those axes are ignored.
|
||||
const scheduleId = 'schedule-built';
|
||||
|
||||
const reservedBooking = (id: string) =>
|
||||
const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
|
||||
({
|
||||
id,
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
|
||||
bookingContainers: [],
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
originYardId: leg?.origin ?? 'yard-a',
|
||||
destinationYardId: leg?.dest ?? 'yard-b',
|
||||
}) as unknown as Booking;
|
||||
|
||||
const buildService = (opts: {
|
||||
physicalWagons: number;
|
||||
reserved: Booking[];
|
||||
maxWagons?: number;
|
||||
routeStops?: string[];
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
@@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
routeId: null,
|
||||
routeId: opts.routeStops ? 'route-1' : null,
|
||||
scheduleBookings: [],
|
||||
trainSet: {
|
||||
locomotive: {
|
||||
@@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
},
|
||||
};
|
||||
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
|
||||
const milestoneRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
(opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })),
|
||||
),
|
||||
};
|
||||
const genericRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) =>
|
||||
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
|
||||
),
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Wagon') return wagonRepo;
|
||||
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
||||
return genericRepo;
|
||||
}),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
const service = new BookingBatchService(
|
||||
@@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
|
||||
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
|
||||
// left the pass-through edges reading "free" in the per-edge budget, so the
|
||||
// full train's window cycled OPEN forever and the day pool never expired.
|
||||
// A wagon is committed for the whole trip — leg-free edges are not capacity.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 1,
|
||||
|
||||
@@ -33,7 +33,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import {
|
||||
BATCH_BOARD_STATUSES,
|
||||
BatchBoardQueryDto,
|
||||
@@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
@@ -171,23 +172,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
consolidationPartnerRef: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
export interface BatchBoardCounts {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
}
|
||||
|
||||
/** A booking bucket on the detail board (in-window vs pending-contract). */
|
||||
export interface BatchBoardBucket {
|
||||
counts: BatchBoardCounts;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
@@ -214,8 +210,9 @@ export interface BatchBoardScheduleDetail {
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
/** Bookings inside the schedule's booking window (fully-executed contracts). */
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
pendingContract: BatchBoardBucket;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
@@ -317,9 +314,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
@Optional() private readonly splitService?: BookingSplitService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => RemainderPlacementService))
|
||||
private readonly remainderPlacement?: RemainderPlacementService,
|
||||
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Auto-place a paid booking's split remainder onto the next fitting train.
|
||||
* Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true.
|
||||
*/
|
||||
private get autoRemainderEnabled(): boolean {
|
||||
return process.env.FREIGHT_AUTO_REMAINDER === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Let EXPORT bookings split (offer the largest fitting part, leftover rebooks
|
||||
* on the next train). Separate flag from auto-remainder: export touches the
|
||||
* FCFS money path, so partial-offer can be enabled independently.
|
||||
*/
|
||||
private get exportSplitEnabled(): boolean {
|
||||
return process.env.FREIGHT_EXPORT_SPLIT === "true";
|
||||
}
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
@@ -496,6 +513,34 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// to the offered part before it boards (remainder returns to the contract cap).
|
||||
if (this.splitService) {
|
||||
await this.splitService.applySplit(bookingId);
|
||||
|
||||
// The split only happens on payment (here) — so auto-placing the remainder
|
||||
// also only happens once the customer has accepted+paid. Re-read to see if
|
||||
// applySplit actually reduced this booking (an open offer existed); if so,
|
||||
// auto-create + place the remainder booking on the next fitting train.
|
||||
// applySplit committed its own transaction before returning, so this reads
|
||||
// the reduced lines. Best-effort: a placement failure never blocks the
|
||||
// paid booking from boarding — the remainder falls back to manual rebook.
|
||||
if (this.autoRemainderEnabled && this.remainderPlacement) {
|
||||
const split = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
// Export remainders only auto-place when export split is on — otherwise
|
||||
// an export booking never splits in the first place.
|
||||
const directionOn =
|
||||
split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled;
|
||||
if (split?.isSplit && directionOn) {
|
||||
await this.remainderPlacement
|
||||
.placeRemainder(split)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Auto-place remainder failed for ${split.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const linked =
|
||||
@@ -731,6 +776,76 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains that can carry a booking's leg on a given day, earliest departure
|
||||
* first, each with the largest number of wagons it could still admit for the
|
||||
* booking's wagon type. Direction-filtered: EXPORT bookings see export trains,
|
||||
* IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL
|
||||
* allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a
|
||||
* non-primary allowed type still counts. The remainder placer uses this to
|
||||
* pick the next fitting train; the `free` wagon count is the best across the
|
||||
* allowed types (a train fits under whichever allowed type gives most room).
|
||||
*/
|
||||
async fittingTrainsForDay(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
direction: "IMPORT" | "EXPORT",
|
||||
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.bookingWindowStatus !== "FULL" &&
|
||||
(direction === "EXPORT"
|
||||
? s.direction === "EXPORT"
|
||||
: s.direction !== "EXPORT"),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const dimsOptions = this.dimsForAllowed(booking, wagonDims);
|
||||
const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
const room = budget.remainingFor(leg);
|
||||
// Best usable wagons across the allowed types — a train fits under
|
||||
// whichever configured wagon type gives it the most room.
|
||||
let freeWagons = 0;
|
||||
for (const dims of dimsOptions) {
|
||||
const w = this.bookableWithin(room, dims).wagons;
|
||||
if (w > freeWagons) freeWagons = w;
|
||||
}
|
||||
if (freeWagons > 0) {
|
||||
out.push({
|
||||
scheduleId: schedule.id,
|
||||
departure: schedule.scheduledDepartureDate!,
|
||||
freeWagons,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
|
||||
* summed across every train on the booking's corridor that day. Unlike the
|
||||
@@ -789,6 +904,58 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return { freeWagons, need, trainsForDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export split: no single train carries the whole booking, so offer the
|
||||
* largest fitting part on the export train with the most room for its leg.
|
||||
* Returns true when an offer was opened (the caller must NOT then reserve —
|
||||
* the offer already opened its own pay window), false when the booking fits
|
||||
* whole somewhere (normal FCFS path) or no meaningful partial exists.
|
||||
*
|
||||
* Only the offer is written here: the booking is reduced to the offered part
|
||||
* on payment (applySplit), and the leftover is auto-placed afterwards. So an
|
||||
* unpaid export booking stays whole and the customer may still cancel it.
|
||||
*/
|
||||
private async tryExportPartialOffer(booking: Booking): Promise<boolean> {
|
||||
if (!this.splitService) return false;
|
||||
const report = await this.exportSpaceReport(booking);
|
||||
// A train fits it whole — nothing to split, take the normal path.
|
||||
if (report.scheduleId) return false;
|
||||
if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false;
|
||||
|
||||
if (!booking.scheduledDate) return false;
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT");
|
||||
if (!fitting.length) return false;
|
||||
// Most room first — the largest single part ships now, the smallest leftover
|
||||
// is what has to find another train.
|
||||
const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0];
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
target.scheduleId,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) return false;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) return false;
|
||||
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
schedule.id,
|
||||
budget.remainingFor(leg),
|
||||
report.need,
|
||||
);
|
||||
if (!offered) return false;
|
||||
this.logger.log(
|
||||
`[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` +
|
||||
`${schedule.id} — leftover rebooks on the next train once paid.`,
|
||||
);
|
||||
this.notifyBoardChanged(schedule.id, "batch_fill");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
|
||||
* A consolidated booking reserves as a pair only once BOTH partners are ready
|
||||
@@ -800,6 +967,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async acceptExportBooking(booking: Booking): Promise<void> {
|
||||
const partnerId = booking.consolidationPartnerId ?? null;
|
||||
if (!partnerId) {
|
||||
// Export split: when no single train carries the whole booking, offer the
|
||||
// largest fitting part instead of failing the accept. The customer pays
|
||||
// that part; on payment applySplit reduces this booking to it and the
|
||||
// leftover is auto-placed as its own booking on the next train. Pairs are
|
||||
// excluded (handled below) — a shared wagon is never split.
|
||||
if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) {
|
||||
const offered = await this.tryExportPartialOffer(booking);
|
||||
if (offered) return;
|
||||
}
|
||||
const scheduleId = await this.pickExportSchedule(booking);
|
||||
await this.reserveOnExport([booking], scheduleId);
|
||||
return;
|
||||
@@ -1013,11 +1189,32 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
// One links query + one bookings query for the whole page (was 2 per card).
|
||||
const scheduleIds = schedules.map((s) => s.id);
|
||||
const [allLinks, allBookings] = await Promise.all([
|
||||
scheduleIds.length
|
||||
? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } })
|
||||
: Promise.resolve([]),
|
||||
this.bookingsRepository.findAllBySchedules(scheduleIds),
|
||||
]);
|
||||
const linkedIdsBySchedule = new Map<string, Set<string>>();
|
||||
for (const l of allLinks) {
|
||||
let set = linkedIdsBySchedule.get(l.trainScheduleId);
|
||||
if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set()));
|
||||
set.add(l.bookingId);
|
||||
}
|
||||
const bookingsBySchedule = new Map<string, Booking[]>();
|
||||
for (const b of allBookings) {
|
||||
if (!b.trainScheduleId) continue;
|
||||
let list = bookingsBySchedule.get(b.trainScheduleId);
|
||||
if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = []));
|
||||
list.push(b);
|
||||
}
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
for (const s of schedules) {
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set<string>();
|
||||
const bookings = bookingsBySchedule.get(s.id) ?? [];
|
||||
|
||||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonDims);
|
||||
@@ -1046,7 +1243,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||
/** Schedule-level batch board: the schedule's own booking window plus its
|
||||
* bookings split into in-window (contract executed) vs pending-contract. */
|
||||
async getBatchBoardDetail(
|
||||
scheduleId: string,
|
||||
): Promise<BatchBoardScheduleDetail> {
|
||||
@@ -1064,17 +1262,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
// The full graph already carries the schedule↔booking links — no separate
|
||||
// link query needed.
|
||||
const linkedIds = new Set(
|
||||
(s.scheduleBookings ?? []).map((l) => l.bookingId),
|
||||
);
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
let allocationPreview: Awaited<
|
||||
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
|
||||
>;
|
||||
try {
|
||||
// Reuse the graph loaded above — the preview otherwise re-loads the same
|
||||
// heavy schedule graph a second time per request.
|
||||
allocationPreview =
|
||||
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
|
||||
await this.trainSchedulingService.previewAllocationForSchedule(
|
||||
s.id,
|
||||
s,
|
||||
);
|
||||
} catch {
|
||||
allocationPreview = {
|
||||
assignedBookingIds: [],
|
||||
@@ -1144,73 +1349,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
// Display windows are the REAL booking-window cycles this schedule was FROZEN
|
||||
// with at creation (import: opens at its stored window time, lasts its rule's
|
||||
// duration, reopens per its rule's delay; export: single FCFS lead window) —
|
||||
// NOT the live global config. A later global-rules edit only re-derives
|
||||
// not-yet-open schedules (restampPendingWindows), so an already-open schedule
|
||||
// must keep drawing from its own snapshot, anchored on its stored open time.
|
||||
// Legacy rows with no snapshot fall back to the live config.
|
||||
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
const num = (v: unknown, fallback: number) => {
|
||||
const n = v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const windowCfg = {
|
||||
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
|
||||
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour),
|
||||
windowDurationHours: num(
|
||||
s.ruleWindowDurationHours,
|
||||
liveCfg.windowDurationHours,
|
||||
),
|
||||
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
|
||||
reopenGapMinutes: num(
|
||||
s.ruleReopenDelayMinutes,
|
||||
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
|
||||
),
|
||||
importWindowLeadDays: num(
|
||||
s.ruleImportWindowLeadDays,
|
||||
liveCfg.importWindowLeadDays,
|
||||
),
|
||||
exportBookingLeadHours: num(
|
||||
s.ruleExportBookingLeadHours,
|
||||
liveCfg.exportBookingLeadHours,
|
||||
),
|
||||
// Frozen close offsets: a snapshot null means "no offset for this train"
|
||||
// and stays null (not the live offset); only legacy rows lacking the
|
||||
// column (undefined) fall back to live config.
|
||||
importCloseOffsetMinutes:
|
||||
s.ruleImportCloseOffsetMinutes !== undefined
|
||||
? s.ruleImportCloseOffsetMinutes
|
||||
: liveCfg.importCloseOffsetMinutes,
|
||||
exportCloseOffsetMinutes:
|
||||
s.ruleExportCloseOffsetMinutes !== undefined
|
||||
? s.ruleExportCloseOffsetMinutes
|
||||
: liveCfg.exportCloseOffsetMinutes,
|
||||
};
|
||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||
s.direction ?? null,
|
||||
departureDate,
|
||||
windowCfg,
|
||||
undefined,
|
||||
s.windowOpensAt ?? null,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
});
|
||||
|
||||
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
|
||||
const counts = emptyCounts();
|
||||
for (const b of bookingsInWindow) {
|
||||
// The board renders ONE booking window — the schedule's own frozen window
|
||||
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
||||
// split into two buckets: contract executed (in the window) vs pending
|
||||
// contract. The old per-cycle window projection was dropped — the UI never
|
||||
// showed it, and reconstructing every cycle cost a config load + grouping
|
||||
// pass per request.
|
||||
const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => {
|
||||
const counts: BatchBoardCounts = {
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
};
|
||||
for (const b of bucket) {
|
||||
if (b.state === "ALLOCATED") counts.allocated += 1;
|
||||
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
|
||||
else if (b.state === "READY") counts.ready += 1;
|
||||
@@ -1221,26 +1375,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return counts;
|
||||
};
|
||||
|
||||
const windows: BatchWindowGroup[] = [];
|
||||
for (const [key, bucket] of windowBuckets) {
|
||||
if (key === "pending-contract" || !bucket.window) continue;
|
||||
const w = bucket.window;
|
||||
windows.push({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
date: w.date,
|
||||
dateLabel: w.dateLabel,
|
||||
start: w.start.toISOString(),
|
||||
end: w.end.toISOString(),
|
||||
counts: countFor(bucket.items),
|
||||
bookings: bucket.items,
|
||||
});
|
||||
}
|
||||
windows.sort(
|
||||
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
|
||||
);
|
||||
|
||||
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
|
||||
const windowBookings = items.filter((i) => i.fullyExecutedAt);
|
||||
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
@@ -1290,14 +1426,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.length,
|
||||
expired: items.filter((i) => i.state === "EXPIRED").length,
|
||||
},
|
||||
windows,
|
||||
bookings: windowBookings,
|
||||
pendingContract: {
|
||||
key: "pending-contract",
|
||||
label: "Pending contract",
|
||||
date: "",
|
||||
dateLabel: "",
|
||||
start: "",
|
||||
end: "",
|
||||
counts: countFor(pendingBookings),
|
||||
bookings: pendingBookings,
|
||||
},
|
||||
@@ -1852,15 +1982,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
|
||||
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
|
||||
* neither shared wagon) and government bookings never split (they preempt).
|
||||
* A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a
|
||||
* partial (split-on-payment). Consolidated pairs never split (both-or-neither
|
||||
* shared wagon) and government bookings never split (they preempt).
|
||||
*
|
||||
* IMPORT is always eligible. EXPORT is eligible only when export split is
|
||||
* enabled: export historically rides one train whole, so splitting it changes
|
||||
* the FCFS money path — each split part still rides ONE train whole, and the
|
||||
* leftover becomes its own booking on the next train.
|
||||
*/
|
||||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||
const directionOk =
|
||||
booking.tradeDirection === "IMPORT" ||
|
||||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
|
||||
return (
|
||||
!isPair &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
directionOk &&
|
||||
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
|
||||
this.splitService != null
|
||||
);
|
||||
@@ -2323,7 +2461,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!schedule || !locomotive) return null;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
// Built trains: collapse to a single train-wide pool so the freed capacity of
|
||||
// a booking that alights mid-corridor is NOT re-offered on the pass-through
|
||||
// leg (see remainingBudget). Keeps intercity accept consistent with the
|
||||
// train-wide isTrainFull / committedWagons finalize signal.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
|
||||
collapseForBuiltTrain: true,
|
||||
});
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
}
|
||||
|
||||
@@ -3117,7 +3261,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
|
||||
* no wagon type configured yet.
|
||||
*/
|
||||
/** Wagon types are near-static reference data — a short TTL cache spares one
|
||||
* table scan per board/detail request without letting edits go stale long. */
|
||||
private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null;
|
||||
|
||||
private async loadWagonDims(): Promise<WagonDims> {
|
||||
if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) {
|
||||
return this.wagonDimsCache.value;
|
||||
}
|
||||
const types = await this.dataSource.getRepository(WagonType).find();
|
||||
const byCode = new Map(
|
||||
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
||||
@@ -3131,7 +3282,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// must fall back rather than yield an infinite wagon count.
|
||||
const payload = (value: number | undefined, fallback: number): number =>
|
||||
value && value > 0 ? value : fallback;
|
||||
return {
|
||||
const value: WagonDims = {
|
||||
container: {
|
||||
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
@@ -3144,6 +3295,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
},
|
||||
byWagonTypeId,
|
||||
};
|
||||
this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3174,6 +3327,40 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EVERY wagon-type dimension a booking may ride — its cargo/container type's
|
||||
* full allowed (many-to-many) wagon-type list, not just the first like
|
||||
* {@link dimsFor}. The remainder placer needs the whole set so a train that
|
||||
* stocks a non-primary allowed type still counts as fitting: a container type
|
||||
* mapped to both NW5 and (say) NW7 must be measured against whichever a given
|
||||
* train actually has free. Deduped by wagon-type id; falls back to the single
|
||||
* representative dims when no allowed type is configured.
|
||||
*/
|
||||
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
|
||||
const fallback =
|
||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||||
const ids =
|
||||
booking.freightType === "BULK"
|
||||
? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id)
|
||||
: (booking.bookingContainers ?? [])
|
||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||
.map((wt) => wt.id);
|
||||
const seen = new Set<string>();
|
||||
const dims: PerWagonDims[] = [];
|
||||
for (const id of ids) {
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const d = wagonDims.byWagonTypeId.get(id);
|
||||
if (d) {
|
||||
dims.push({
|
||||
...d,
|
||||
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dims.length ? dims : [fallback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||
@@ -3211,6 +3398,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedule: TrainSchedule,
|
||||
limits: TrainLimits,
|
||||
wagonDims: WagonDims,
|
||||
opts?: { collapseForBuiltTrain?: boolean },
|
||||
): Promise<CorridorBudget> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
@@ -3223,7 +3411,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
}
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
// A built train's wagons are coupled for the WHOLE trip, and the allocator
|
||||
// commits each booking to a wagon for the entire route — it never reloads a
|
||||
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
|
||||
// its capacity is one train-wide pool, exactly as isTrainFull /
|
||||
// committedWagons already count it. When a caller opts in, collapse the
|
||||
// corridor to a single whole-route edge so every booking (full-route OR
|
||||
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
|
||||
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
|
||||
// leg it merely passes through, instead of over-promising the freed slots.
|
||||
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
|
||||
// abstract slot/weight/length budget genuinely frees past an alight yard.
|
||||
const stops =
|
||||
physicalWagons != null && opts?.collapseForBuiltTrain
|
||||
? [schedule.originStationId, schedule.destinationStationId]
|
||||
: await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
@@ -3385,11 +3587,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
// Built train: the physical consist is the only capacity axis, and a wagon
|
||||
// is committed to its booking for the WHOLE trip — wagon allocation has no
|
||||
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
|
||||
// the Doraleh→Negad edge it merely passes through. Count commitments
|
||||
// train-wide, not per corridor edge: the per-edge budget read "free slots"
|
||||
// on pass-through legs of a sold-out consist, so the window of a full train
|
||||
// cycled OPEN forever instead of concluding DONE (and the day pool's
|
||||
// leftover bookings were never expired).
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
return (await this.committedWagons(schedule)) >= physicalWagons;
|
||||
}
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||
// Built train: the physical consist is the only capacity axis. Weight and
|
||||
// length were enforced when the consist was assembled (builder /
|
||||
// adjust-consist), so a free wagon slot means the train genuinely has room.
|
||||
if ((await this.builtTrainWagonCount(schedule)) != null) return false;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!locomotive) return false; // no weight/length limits to bind against
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
@@ -3398,6 +3608,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
|
||||
* regardless of which corridor leg each rides. Deduped by booking id — a
|
||||
* booking mid-settle can momentarily be both linked and reserved.
|
||||
*/
|
||||
private async committedWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const byId = new Map(
|
||||
[...allocated, ...reserved].map((b) => [b.id, b] as const),
|
||||
);
|
||||
let total = 0;
|
||||
for (const booking of byId.values()) {
|
||||
total += this.wagonsFor(booking, wagonDims);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest gross weight / shortest length one more wagon could add: the
|
||||
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
|
||||
|
||||
@@ -352,10 +352,19 @@ export class BookingJourneyService {
|
||||
): Promise<void> {
|
||||
if (booking.tradeDirection !== 'DOMESTIC') return;
|
||||
const facility = await this.yardFacilities.facilityForYard(yardId);
|
||||
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
|
||||
|
||||
if (!facility?.hasFacility) {
|
||||
throw new BadRequestException(
|
||||
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
|
||||
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
|
||||
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
|
||||
);
|
||||
}
|
||||
// A facility only handles what its equipment can lift: containers need a
|
||||
// reach stacker/gantry, bulk does not.
|
||||
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
|
||||
throw new BadRequestException(
|
||||
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
|
||||
`an intercity booking cannot be ${where} here.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,10 +150,18 @@ export class BookingNotifierService {
|
||||
): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const leftover = totalWagons - offeredWagons;
|
||||
// With auto-placement on, the leftover is booked FOR the customer on another
|
||||
// train (its own invoice) — telling them to rebook it themselves would be
|
||||
// wrong. Without it, the leftover returns to the contract to rebook.
|
||||
const leftoverCopy =
|
||||
process.env.FREIGHT_AUTO_REMAINDER === 'true'
|
||||
? `The remaining ${leftover} will be booked for you on another train, with its own invoice. `
|
||||
: `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `;
|
||||
const msg =
|
||||
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
|
||||
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
|
||||
leftoverCopy +
|
||||
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||
// HIGH: a split is a change to what the customer ordered AND a live payment
|
||||
@@ -164,6 +172,23 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The wagons that did not fit the train the customer just paid for have been
|
||||
* auto-booked as their own booking (`remainder`) — they ride another train and
|
||||
* are billed separately. Sent instead of leaving the customer to rebook.
|
||||
*/
|
||||
remainderPlaced(remainder: Booking, parentReference: string): void {
|
||||
const msg =
|
||||
`The wagons left over from booking ${parentReference} have been booked as ` +
|
||||
`${remainder.reference ?? remainder.id} on another train. ` +
|
||||
`It carries its own invoice — pay it to secure that slot.`;
|
||||
void this.notifyContact(remainder, msg, 'REMAINDER BOOKED');
|
||||
this.inApp(remainder, 'Leftover wagons booked', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@IsInt()
|
||||
@@ -21,9 +23,18 @@ export class RecordCheckpointDto {
|
||||
@IsEnum(TrainCheckpointKind)
|
||||
kind?: TrainCheckpointKind;
|
||||
|
||||
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
|
||||
/**
|
||||
* A checkpoint records where the train is as staff observe it, and the final
|
||||
* one arrives the schedule — so a backdated value rewrites the journey after
|
||||
* the fact. Only "now" is accepted; omit the field and the service stamps it.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@IsNotBackdated()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
|
||||
@@ -67,10 +67,18 @@ export class IntercityService {
|
||||
ts.status AS "scheduleStatus",
|
||||
oy.id AS "originYardId",
|
||||
COALESCE(oy.label, oy.code) AS "origin",
|
||||
oy.has_facility AS "originHasFacility",
|
||||
-- Can that end actually handle THIS booking's cargo? A container
|
||||
-- booking needs a facility with a stacker; bulk needs any facility.
|
||||
(oy.has_facility AND COALESCE(
|
||||
CASE WHEN b.freight_type = 'CONTAINER'
|
||||
THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
|
||||
AS "originHasFacility",
|
||||
dy.id AS "destinationYardId",
|
||||
COALESCE(dy.label, dy.code) AS "destination",
|
||||
dy.has_facility AS "destinationHasFacility",
|
||||
(dy.has_facility AND COALESCE(
|
||||
CASE WHEN b.freight_type = 'CONTAINER'
|
||||
THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
|
||||
AS "destinationHasFacility",
|
||||
-- Where the train actually is, so the operator knows if the cargo
|
||||
-- can be worked right now.
|
||||
cp.yard_id AS "trainAtYardId",
|
||||
@@ -80,6 +88,10 @@ export class IntercityService {
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.yard_facilities ofac
|
||||
ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
|
||||
LEFT JOIN freight.yard_facilities dfac
|
||||
ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
|
||||
LEFT JOIN freight.train_schedules ts
|
||||
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -104,7 +116,8 @@ export class IntercityService {
|
||||
|
||||
async listCandidates(scheduleId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||
const milestones = await this.routeMilestones(schedule);
|
||||
const milestoneSeq = this.milestoneSequenceOf(schedule, milestones);
|
||||
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
|
||||
|
||||
const waiting = milestoneSeq
|
||||
@@ -112,29 +125,45 @@ export class IntercityService {
|
||||
: [];
|
||||
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
|
||||
|
||||
// Mid-corridor intercity matching needs a real stop list (>= 2 route
|
||||
// milestones). Without one the fallback is a 2-stop origin->destination
|
||||
// pseudo-route that only matches bookings on the train's exact corridor —
|
||||
// surface that so an empty candidate list isn't misread as "nobody waiting".
|
||||
const warning =
|
||||
milestoneSeq == null
|
||||
? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.'
|
||||
: schedule.routeId && milestones.length < 2
|
||||
? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear."
|
||||
: null;
|
||||
|
||||
return {
|
||||
scheduleId,
|
||||
routeId: schedule.routeId ?? null,
|
||||
warning,
|
||||
// Segment-based: "remaining" is the most-open edge; each candidate's
|
||||
// `fits` is judged against ITS OWN leg, so a booking on a free leg fits
|
||||
// even when the train is full elsewhere.
|
||||
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||
candidates: waiting.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
const leg = capacity?.budget.legOf(
|
||||
// legForYards, not legOf: on a built train the budget is a single
|
||||
// whole-route edge (see intercityCapacity), so a mid-corridor booking
|
||||
// must draw from that one pool via the whole-route fallback. On a
|
||||
// locomotive-derived schedule it still resolves to the booking's own leg.
|
||||
const leg = capacity?.budget.legForYards(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
return {
|
||||
...this.mapBooking(booking),
|
||||
...this.mapBooking(booking, need),
|
||||
need,
|
||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => ({
|
||||
...this.mapBooking(booking),
|
||||
need: capacity?.needFor(booking) ?? null,
|
||||
})),
|
||||
accepted: accepted.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
return { ...this.mapBooking(booking, need), need };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,14 +215,17 @@ export class IntercityService {
|
||||
continue;
|
||||
}
|
||||
const need = capacity.needFor(booking);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
// Segment-based: only the booking's own leg must have room, so an
|
||||
// intercity booking still boards a train that is full on other legs.
|
||||
if (!leg || !budget.fits(need, leg)) {
|
||||
// legForYards, not legOf: a built train's budget is a single whole-route
|
||||
// pool (mid-corridor wagons are committed for the whole trip and never
|
||||
// reloaded), so the booking draws from that pool via the whole-route
|
||||
// fallback; a locomotive-derived schedule still gets the booking's own
|
||||
// leg, so it can still board a train that is full only on other legs.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
if (!budget.fits(need, leg)) {
|
||||
rejected.push({
|
||||
bookingId,
|
||||
reason:
|
||||
'Does not fit the remaining wagon/weight/length capacity on its leg',
|
||||
'Does not fit the remaining wagon/weight/length capacity for this train',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -244,16 +276,21 @@ export class IntercityService {
|
||||
* so an intercity booking exactly matching the train's own corridor still
|
||||
* qualifies.
|
||||
*/
|
||||
private async routeMilestoneSequence(
|
||||
private async routeMilestones(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<Map<string, number> | null> {
|
||||
if (schedule.routeId) {
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
if (milestones.length >= 2) {
|
||||
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
|
||||
}
|
||||
): Promise<RouteMilestone[]> {
|
||||
if (!schedule.routeId) return [];
|
||||
return this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
}
|
||||
|
||||
private milestoneSequenceOf(
|
||||
schedule: TrainSchedule,
|
||||
milestones: RouteMilestone[],
|
||||
): Map<string, number> | null {
|
||||
if (milestones.length >= 2) {
|
||||
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
|
||||
}
|
||||
if (schedule.originStationId && schedule.destinationStationId) {
|
||||
return new Map([
|
||||
@@ -264,6 +301,15 @@ export class IntercityService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async routeMilestoneSequence(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<Map<string, number> | null> {
|
||||
return this.milestoneSequenceOf(
|
||||
schedule,
|
||||
await this.routeMilestones(schedule),
|
||||
);
|
||||
}
|
||||
|
||||
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
|
||||
private async findWaitingIntercityBookings(
|
||||
milestoneSeq: Map<string, number>,
|
||||
@@ -356,7 +402,12 @@ export class IntercityService {
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
private mapBooking(booking: Booking) {
|
||||
/**
|
||||
* `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is
|
||||
* spent in. Prefer it, so the row's weight sits on the same axis as the
|
||||
* remaining-capacity figure shown beside it; cargo VGM is the fallback.
|
||||
*/
|
||||
private mapBooking(booking: Booking, need?: { weightTons: number } | null) {
|
||||
return {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
@@ -372,7 +423,7 @@ export class IntercityService {
|
||||
booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
'Unknown destination',
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,8 @@ export class TrainSchedulingController {
|
||||
@Get("batch-board/:scheduleId")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: "Batch board detail for one schedule with EAT 3h windows",
|
||||
summary:
|
||||
"Batch board detail for one schedule: its booking window, in-window bookings and pending-contract bucket",
|
||||
})
|
||||
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
|
||||
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
|
||||
|
||||
@@ -34,6 +34,7 @@ import { IntercityService } from './intercity.service';
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
@@ -82,6 +83,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
WsAuthService,
|
||||
BookingWindowService,
|
||||
BookingSplitService,
|
||||
RemainderPlacementService,
|
||||
IntercityService,
|
||||
BookingJourneyService,
|
||||
FacilityHandlingService,
|
||||
|
||||
@@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('marshalling documents', () => {
|
||||
// Staff check these against the physical consist, so every wagon on the
|
||||
// train set has to appear — an empty wagon that renders no row reads as a
|
||||
// wagon that is not on the train.
|
||||
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
|
||||
sequenceNo,
|
||||
wagonNumber,
|
||||
physicalWagon: { wagonNumber },
|
||||
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
|
||||
lengthMeters: 14,
|
||||
capacityTons: 70,
|
||||
allocations,
|
||||
});
|
||||
|
||||
const loadedAllocation = {
|
||||
bookingId: 'booking-1',
|
||||
bookingReference: 'BK-2026-000001',
|
||||
loadType: 'CONTAINER',
|
||||
allocatedWeightTons: 24.5,
|
||||
containerNumbers: ['CONT-001'],
|
||||
booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
|
||||
containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
|
||||
};
|
||||
|
||||
const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;
|
||||
|
||||
it('lists an empty wagon on the export document and marks it EMPTY', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [
|
||||
makeWagon(1, 'W-001', [loadedAllocation]),
|
||||
makeWagon(2, 'W-002', []),
|
||||
makeWagon(3, 'W-003', []),
|
||||
],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(countRows(html)).toBe(3);
|
||||
expect(html).toContain('W-002');
|
||||
expect(html).toContain('W-003');
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
|
||||
// The wagon count must agree with the rows the reader can see.
|
||||
expect(html).toContain('3 (2 empty)');
|
||||
});
|
||||
|
||||
it('lists an empty wagon on the import document and marks it EMPTY', () => {
|
||||
const loadList = {
|
||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||
trainScheduleId: 'schedule-1',
|
||||
trainNumber: '8002',
|
||||
route: 'Djibouti → Indode',
|
||||
origin: 'Djibouti Port',
|
||||
destination: 'Indode',
|
||||
totalBookings: 1,
|
||||
wagons: [
|
||||
{ sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
|
||||
{ sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
|
||||
],
|
||||
operation: { status: {} },
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildImportLoadListHtml: (l: unknown) => string;
|
||||
}).buildImportLoadListHtml(loadList);
|
||||
|
||||
expect(countRows(html)).toBe(2);
|
||||
expect(html).toContain('W-002');
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('renders wagons in consist order regardless of the order the relation returns', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
|
||||
expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
|
||||
});
|
||||
|
||||
it('omits the empty-count suffix when every wagon is loaded', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(html).not.toContain('empty)');
|
||||
expect(html).not.toContain('EMPTY');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,6 +267,8 @@ export interface CompositionUnassignedBookingRow {
|
||||
freightType: string | null;
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
|
||||
grossWeightTons: number;
|
||||
status: string | null;
|
||||
schedulingStatus: string | null;
|
||||
wagonsRequired: number;
|
||||
@@ -477,6 +479,38 @@ export class TrainSchedulingService {
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* A built train makes at most ONE departure per route per EAT day. Returns
|
||||
* the non-cancelled schedule already holding this train on this route for
|
||||
* `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
|
||||
* legal — siblings must be different trains.
|
||||
*/
|
||||
private async findTrainRouteDayConflict(
|
||||
trainId: string,
|
||||
routeId: string,
|
||||
departure: Date,
|
||||
excludeScheduleId?: string,
|
||||
): Promise<TrainSchedule | null> {
|
||||
const day = eatDay(departure);
|
||||
const dayStart = eatDayToUtc(day, 0);
|
||||
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
|
||||
const qb = this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('s')
|
||||
.innerJoin('s.trainSet', 'ts')
|
||||
.where('ts.trainId = :trainId', { trainId })
|
||||
.andWhere('s.routeId = :routeId', { routeId })
|
||||
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
|
||||
.andWhere('s.status != :cancelledStatus', {
|
||||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||||
});
|
||||
if (excludeScheduleId) {
|
||||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||
}
|
||||
return qb.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* The window timeline a brand-new schedule must adopt to join its route+day
|
||||
* group. Returns the canonical open/close times + rule snapshot copied from an
|
||||
@@ -880,6 +914,28 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Moving onto a day where this same built train already runs this route
|
||||
// would double-book the physical train — blocked for planning moves.
|
||||
if (schedule.trainSetId && schedule.routeId) {
|
||||
const trainSet = await this.dataSource
|
||||
.getRepository(TrainSet)
|
||||
.findOne({ where: { id: schedule.trainSetId } });
|
||||
if (trainSet?.trainId) {
|
||||
const conflict = await this.findTrainRouteDayConflict(
|
||||
trainSet.trainId,
|
||||
schedule.routeId,
|
||||
departure,
|
||||
id,
|
||||
);
|
||||
if (conflict) {
|
||||
throw new ConflictException(
|
||||
`This train is already scheduled on this route for that day ` +
|
||||
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-derive the window from the schedule's own rule snapshot (falling back to
|
||||
// the live config where a legacy row has no snapshot) against the new date.
|
||||
const merged = effectiveWindowConfig(schedule, windowCfg);
|
||||
@@ -913,10 +969,37 @@ export class TrainSchedulingService {
|
||||
scheduledDepartureDate: departure,
|
||||
...windowFields,
|
||||
});
|
||||
|
||||
// Only customers whose bookings already HOLD wagons on this train are told
|
||||
// about the move (SMS + email + portal inbox). Linked-but-unallocated
|
||||
// bookings are skipped — nothing of theirs is riding this departure yet.
|
||||
let notifiedCount = 0;
|
||||
if (schedule.trainSetId) {
|
||||
const allocations = await this.dataSource
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('a')
|
||||
.innerJoin('a.trainSetWagon', 'slot')
|
||||
.where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
|
||||
.getMany();
|
||||
const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
|
||||
if (allocatedBookingIds.length) {
|
||||
const allocatedBookings = await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(allocatedBookingIds) },
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const booking of allocatedBookings) {
|
||||
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
|
||||
this.bookingNotifier.rescheduled(booking, departure);
|
||||
notifiedCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||||
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
|
||||
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
|
||||
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
|
||||
`${notifiedCount} allocated customer booking(s) notified`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
@@ -1200,7 +1283,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
if (
|
||||
builtTrain.status === Freight.TrainStatus.OutOfService ||
|
||||
builtTrain.status === Freight.TrainStatus.UnderMaintenance
|
||||
builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
|
||||
builtTrain.status === Freight.TrainStatus.Deactivated
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
@@ -1220,6 +1304,17 @@ export class TrainSchedulingService {
|
||||
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
||||
);
|
||||
}
|
||||
const conflict = await this.findTrainRouteDayConflict(
|
||||
builtTrain.id,
|
||||
route.id,
|
||||
new Date(dto.scheduleDate),
|
||||
);
|
||||
if (conflict) {
|
||||
throw new ConflictException(
|
||||
`Train ${builtTrain.code} is already scheduled on this route for that day ` +
|
||||
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
|
||||
if (locomotiveIds.length < 2) {
|
||||
@@ -1679,6 +1774,7 @@ export class TrainSchedulingService {
|
||||
scheduleId,
|
||||
schedule.originStationId,
|
||||
savedWagons,
|
||||
schedule.reverseWagonOrder ?? false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2590,19 +2686,24 @@ export class TrainSchedulingService {
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
// Every wagon on the train set, loaded or not, in consist order. An empty
|
||||
// wagon has an empty `allocations` array — it is still part of the train
|
||||
// and still belongs on the marshalling document.
|
||||
wagons: [...(schedule.trainSet?.wagons ?? [])]
|
||||
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
|
||||
.map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||
};
|
||||
}
|
||||
@@ -2652,9 +2753,33 @@ export class TrainSchedulingService {
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
const rows = (schedule.trainSet?.wagons ?? [])
|
||||
.flatMap((wagon) =>
|
||||
(wagon.allocations ?? []).map((allocation) => {
|
||||
// The document is checked against the physical train, so it has to run in
|
||||
// consist order — the relation comes back unordered.
|
||||
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
|
||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||
);
|
||||
const rows = wagons
|
||||
.flatMap((wagon) => {
|
||||
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
|
||||
const allocations = wagon.allocations ?? [];
|
||||
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
||||
// check this document against the physical train — a wagon with no row
|
||||
// reads as a wagon that is not there, and the count stops matching.
|
||||
if (allocations.length === 0) {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="6">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
@@ -2664,12 +2789,7 @@ export class TrainSchedulingService {
|
||||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||
${wagonCells}
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
@@ -2677,10 +2797,11 @@ export class TrainSchedulingService {
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
<td>${esc(sealNumbers)}</td>
|
||||
</tr>`;
|
||||
}),
|
||||
)
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
||||
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
@@ -2708,6 +2829,8 @@ export class TrainSchedulingService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tr.empty td { background: #f8fafc; color: #64748b; }
|
||||
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
@@ -2735,7 +2858,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||
@@ -2759,7 +2882,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
||||
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -2802,19 +2925,31 @@ export class TrainSchedulingService {
|
||||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) =>
|
||||
wagon.allocations.map(
|
||||
.flatMap((wagon) => {
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>`;
|
||||
// An empty wagon still runs in the consist, so it still gets a line — see
|
||||
// buildExportLoadListHtml.
|
||||
if (wagon.allocations.length === 0) {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return wagon.allocations.map(
|
||||
(allocation) => `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>
|
||||
${wagonCells}
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`,
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
@@ -2846,6 +2981,8 @@ export class TrainSchedulingService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tr.empty td { background: #f8fafc; color: #64748b; }
|
||||
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||||
@@ -2872,7 +3009,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
@@ -2900,7 +3037,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
|
||||
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3866,11 +4003,18 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const totalWeightTons = totalAssignedWeight(fittingBookings);
|
||||
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
|
||||
// figure spent against it must be gross too — cargo alone under-reports the
|
||||
// train by the full consist tare and disagrees with the assign path.
|
||||
const totalTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||
);
|
||||
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
if (totalWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (grossWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (!violations.includes(message) && !warnings.includes(message)) {
|
||||
pushLimit([message]);
|
||||
}
|
||||
@@ -3897,7 +4041,7 @@ export class TrainSchedulingService {
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||
totalWeightTons ||
|
||||
grossWeightTons ||
|
||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||
totalLengthMeters)
|
||||
) {
|
||||
@@ -3918,7 +4062,7 @@ export class TrainSchedulingService {
|
||||
!inServiceLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||
totalWeightTons &&
|
||||
grossWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||
totalLengthMeters,
|
||||
)
|
||||
@@ -3940,6 +4084,9 @@ export class TrainSchedulingService {
|
||||
summary: {
|
||||
totalBookings: fittingBookings.length,
|
||||
totalWeightTons,
|
||||
/** GROSS: cargo + the tare of every wagon in the plan. */
|
||||
grossWeightTons,
|
||||
totalTareTons,
|
||||
// Human-readable wagon type(s) of the plan — mixed consists list all.
|
||||
wagonType: plannedTypeCodes.join('/') || 'NONE',
|
||||
wagonsNeeded: wagonPlan.length,
|
||||
@@ -4271,6 +4418,7 @@ export class TrainSchedulingService {
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
slots: TrainSetWagon[],
|
||||
reverseWagonOrder = false,
|
||||
) {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||
@@ -4316,6 +4464,7 @@ export class TrainSchedulingService {
|
||||
assignedPhysicalIds,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
reverseWagonOrder,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
@@ -4409,6 +4558,7 @@ export class TrainSchedulingService {
|
||||
assignedPhysicalIds: Set<string>,
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
reverseWagonOrder = false,
|
||||
): Wagon | undefined {
|
||||
const usable = (wagon: Wagon): boolean => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
@@ -4429,12 +4579,26 @@ export class TrainSchedulingService {
|
||||
// wherever they currently sit (they travel with the train), never a loose
|
||||
// yard wagon.
|
||||
if (builtTrainId) {
|
||||
return wagons.find(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
!assignedPhysicalIds.has(w.id),
|
||||
);
|
||||
// Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
|
||||
// consist views draw the schedule exactly like the train builder; a schedule
|
||||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
||||
const candidates = wagons
|
||||
.filter(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
!assignedPhysicalIds.has(w.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||||
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
|
||||
}
|
||||
return reverseWagonOrder
|
||||
? b.sequenceNumber - a.sequenceNumber
|
||||
: a.sequenceNumber - b.sequenceNumber;
|
||||
});
|
||||
return candidates[0];
|
||||
}
|
||||
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
|
||||
// fall back to one riding from the train's origin.
|
||||
@@ -5097,7 +5261,11 @@ export class TrainSchedulingService {
|
||||
const trains = await this.dataSource.getRepository(Train).find({
|
||||
where: {
|
||||
status: Not(
|
||||
In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]),
|
||||
In([
|
||||
Freight.TrainStatus.OutOfService,
|
||||
Freight.TrainStatus.UnderMaintenance,
|
||||
Freight.TrainStatus.Deactivated,
|
||||
]),
|
||||
),
|
||||
},
|
||||
relations: {
|
||||
@@ -5497,8 +5665,8 @@ export class TrainSchedulingService {
|
||||
* Re-derive a built train's lifecycle status from its schedules after one of
|
||||
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
|
||||
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
|
||||
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE)
|
||||
* keep their status — staff own that flag, not the scheduler.
|
||||
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
|
||||
* / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
|
||||
*/
|
||||
private async syncBuiltTrainAfterScheduleChange(
|
||||
manager: EntityManager,
|
||||
@@ -6208,11 +6376,21 @@ export class TrainSchedulingService {
|
||||
* engine's representative fallbacks for bookings whose cargo/container type
|
||||
* has no wagon type configured. Loaded once per request before mapping.
|
||||
*/
|
||||
/** Wagon types are near-static reference data — a short TTL cache spares one
|
||||
* table scan per detail/board request without letting edits go stale long. */
|
||||
private wagonTareDimsCache: {
|
||||
value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
private async loadWagonTareDims(): Promise<{
|
||||
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
|
||||
bulk: { tareWeightTons: number; capacityTons: number };
|
||||
container: { tareWeightTons: number; capacityTons: number };
|
||||
}> {
|
||||
if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
|
||||
return this.wagonTareDimsCache.value;
|
||||
}
|
||||
const types = await this.dataSource.getRepository(WagonType).find();
|
||||
const byWagonTypeId = new Map(
|
||||
types.map((t) => [
|
||||
@@ -6223,7 +6401,7 @@ export class TrainSchedulingService {
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
const value = {
|
||||
byWagonTypeId,
|
||||
bulk: {
|
||||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
@@ -6234,6 +6412,8 @@ export class TrainSchedulingService {
|
||||
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||||
},
|
||||
};
|
||||
this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6290,49 +6470,14 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const allocationIds = allocations.map((a) => a.id);
|
||||
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
|
||||
// Booking weights are reported GROSS (cargo + wagon tare) — the number the
|
||||
// locomotive actually hauls and the axis its pull limit is compared against.
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
|
||||
// Import-from-Djibouti trains can only dispatch once loading is confirmed
|
||||
// (loadedOnTrainAt on the operation). Other directions have no departure
|
||||
// loading gate, so the workspace shows the confirm button as already done.
|
||||
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
|
||||
let loadingConfirmed = !requiresLoadingConfirmation;
|
||||
if (requiresLoadingConfirmation) {
|
||||
const op = await this.dataSource
|
||||
.getRepository(ImportDjiboutiOperation)
|
||||
.findOne({ where: { trainScheduleId: schedule.id } });
|
||||
loadingConfirmed = Boolean(op?.loadedOnTrainAt);
|
||||
}
|
||||
|
||||
const windowCfg = await this.getWindowConfig();
|
||||
|
||||
const [containerItems, bulkLoads] = await Promise.all([
|
||||
allocationIds.length
|
||||
? this.wagonAllocationContainerItemsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { containerType: true, bookingContainer: true },
|
||||
})
|
||||
: [],
|
||||
allocationIds.length
|
||||
? this.wagonAllocationBulkLoadsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { cargoType: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const containerItemsByAllocation = new Map<string, typeof containerItems>();
|
||||
for (const item of containerItems) {
|
||||
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
|
||||
list.push(item);
|
||||
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
|
||||
}
|
||||
const bulkLoadsByAllocation = new Map(
|
||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||||
);
|
||||
|
||||
// Snapshot state decides below whether the live consist may be drawn at
|
||||
// all, so it is derived before the consist wagons are fetched.
|
||||
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
|
||||
// and re-pinned onto later trains — the live wagon↔slot joins no longer
|
||||
// describe THIS train. If a frozen snapshot was captured at the transition,
|
||||
@@ -6347,6 +6492,55 @@ export class TrainSchedulingService {
|
||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||
);
|
||||
|
||||
// All independent lookups fired at once — they used to run one after
|
||||
// another, stacking round-trips onto every detail request.
|
||||
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
|
||||
// number the locomotive actually hauls against its pull limit.
|
||||
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
|
||||
await Promise.all([
|
||||
this.loadWagonTareDims(),
|
||||
requiresLoadingConfirmation
|
||||
? this.dataSource
|
||||
.getRepository(ImportDjiboutiOperation)
|
||||
.findOne({ where: { trainScheduleId: schedule.id } })
|
||||
: null,
|
||||
this.getWindowConfig(),
|
||||
allocationIds.length
|
||||
? this.wagonAllocationContainerItemsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { containerType: true, bookingContainer: true },
|
||||
})
|
||||
: [],
|
||||
allocationIds.length
|
||||
? this.wagonAllocationBulkLoadsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { cargoType: true },
|
||||
})
|
||||
: [],
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
// Mirror the pinning direction: a reverse-order schedule draws the
|
||||
// whole consist back-to-front, empties included.
|
||||
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const loadingConfirmed = requiresLoadingConfirmation
|
||||
? Boolean(importOp?.loadedOnTrainAt)
|
||||
: true;
|
||||
|
||||
const containerItemsByAllocation = new Map<string, typeof containerItems>();
|
||||
for (const item of containerItems) {
|
||||
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
|
||||
list.push(item);
|
||||
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
|
||||
}
|
||||
const bulkLoadsByAllocation = new Map(
|
||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||||
);
|
||||
|
||||
// The trainSet slots below are the PLANNED wagons (one per allocation). A
|
||||
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
|
||||
// included (the pull-limit check already counts their tare) — so append the
|
||||
@@ -6369,41 +6563,32 @@ export class TrainSchedulingService {
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
);
|
||||
const emptyConsistWagons =
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? (
|
||||
await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
})
|
||||
)
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}))
|
||||
: [];
|
||||
const emptyConsistWagons = rawConsistWagons
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
@@ -6413,6 +6598,7 @@ export class TrainSchedulingService {
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
maxWagons: schedule.maxWagons ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
||||
requiresLoadingConfirmation,
|
||||
loadingConfirmed,
|
||||
// Booking-window phase + phase deadlines drive the countdown timers in the
|
||||
@@ -6713,8 +6899,13 @@ export class TrainSchedulingService {
|
||||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||||
async previewAllocationForSchedule(
|
||||
scheduleId: string,
|
||||
// Callers that already hold the full schedule graph (batch board detail)
|
||||
// pass it in so the preview doesn't re-load the same heavy graph.
|
||||
preloadedSchedule?: TrainSchedule,
|
||||
): Promise<WagonAllocationAttemptResult> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const schedule =
|
||||
preloadedSchedule ??
|
||||
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
@@ -6761,7 +6952,10 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (!eligible.length) return empty;
|
||||
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id);
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(
|
||||
schedule.id,
|
||||
schedule,
|
||||
);
|
||||
const previewDto = {
|
||||
bookingIds: eligible.map((b) => b.id),
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
@@ -7036,6 +7230,15 @@ export class TrainSchedulingService {
|
||||
shortfall: 0,
|
||||
}));
|
||||
|
||||
// Gross weight needs the scheduling graph (containers, cargo type, wagon
|
||||
// types) that the trimmed select above deliberately skips.
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
const fullById = new Map(
|
||||
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
|
||||
(b) => [b.id, b],
|
||||
),
|
||||
);
|
||||
|
||||
const bookings = await Promise.all(
|
||||
unassigned.map(async (b) => {
|
||||
const assignability = await this.previewUnassignedBookingAssignability(
|
||||
@@ -7050,6 +7253,11 @@ export class TrainSchedulingService {
|
||||
freightType: b.freightType ?? null,
|
||||
priorityScore: b.priorityScore ?? 0,
|
||||
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
|
||||
// GROSS: cargo + tare of the wagons the booking occupies.
|
||||
grossWeightTons: this.grossBookingWeightTons(
|
||||
(fullById.get(b.id) ?? b) as Booking,
|
||||
tareDims,
|
||||
),
|
||||
status: b.status ?? null,
|
||||
schedulingStatus: b.schedulingStatus ?? null,
|
||||
...assignability,
|
||||
@@ -7278,8 +7486,15 @@ export class TrainSchedulingService {
|
||||
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
|
||||
}
|
||||
|
||||
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
private async getWagonAssignedBookingIds(
|
||||
scheduleId: string,
|
||||
// Pass when the caller already holds the schedule with trainSet.wagons —
|
||||
// only wagon ids are read here, the old full-graph reload was pure waste.
|
||||
preloadedSchedule?: TrainSchedule,
|
||||
): Promise<Set<string>> {
|
||||
const schedule =
|
||||
preloadedSchedule ??
|
||||
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
|
||||
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
||||
if (!wagonIds.length) return new Set();
|
||||
|
||||
|
||||
@@ -44,6 +44,15 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.listBuilt(query);
|
||||
}
|
||||
|
||||
// Must be declared before @Get(':id') so the path isn't captured as an id.
|
||||
@Get('used-train-numbers')
|
||||
@ApiOperation({
|
||||
summary: 'Import/export run numbers already claimed by existing trains',
|
||||
})
|
||||
usedTrainNumbers() {
|
||||
return this.trainBuilderService.usedTrainNumbers();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
|
||||
composition(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@@ -115,6 +124,22 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.reorderWagons(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/deactivate')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
||||
})
|
||||
deactivate(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.deactivate(id);
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
||||
activate(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.activate(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -153,6 +153,38 @@ export class TrainBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run numbers already claimed by live (non-deleted) trains, split by
|
||||
* direction. Legacy single `train_number` values are sorted into a side by
|
||||
* parity (even = import, odd = export) so the pickers can grey them out too.
|
||||
*/
|
||||
async usedTrainNumbers() {
|
||||
const rows: {
|
||||
import_train_number: string | null;
|
||||
export_train_number: string | null;
|
||||
train_number: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
`SELECT import_train_number, export_train_number, train_number
|
||||
FROM freight.trains
|
||||
WHERE deleted_at IS NULL`,
|
||||
);
|
||||
|
||||
const importTrainNumbers = new Set<string>();
|
||||
const exportTrainNumbers = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.import_train_number) importTrainNumbers.add(row.import_train_number);
|
||||
if (row.export_train_number) exportTrainNumbers.add(row.export_train_number);
|
||||
const legacy = row.train_number?.trim();
|
||||
if (legacy && /^\d+$/.test(legacy)) {
|
||||
(Number(legacy) % 2 === 0 ? importTrainNumbers : exportTrainNumbers).add(legacy);
|
||||
}
|
||||
}
|
||||
return {
|
||||
importTrainNumbers: [...importTrainNumbers].sort(),
|
||||
exportTrainNumbers: [...exportTrainNumbers].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
|
||||
* else the earliest upcoming departure) — feeds the list's direction tint
|
||||
@@ -532,6 +564,50 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Park the train indefinitely (status DEACTIVATED). Blocked while it still
|
||||
* has a live (DRAFT/SCHEDULED/DISPATCHED) schedule. The consist stays
|
||||
* coupled; like UNDER_MAINTENANCE / OUT_OF_SERVICE the flag is staff-owned —
|
||||
* the scheduler never overwrites it and refuses the train for new schedules.
|
||||
*/
|
||||
async deactivate(id: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await manager.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.Deactivated) return;
|
||||
const active: { count: string }[] = await manager.query(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
||||
[id],
|
||||
);
|
||||
if (Number(active[0]?.count ?? 0) > 0) {
|
||||
throw new ConflictException(
|
||||
'Train has active schedules; cancel them before deactivating the train',
|
||||
);
|
||||
}
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
.update(id, { status: Freight.TrainStatus.Deactivated });
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */
|
||||
async activate(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.Deactivated) {
|
||||
await this.dataSource
|
||||
.getRepository(Train)
|
||||
.update(id, { status: Freight.TrainStatus.Available });
|
||||
}
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Disband the train: release wagons and locomotives, then delete it. */
|
||||
async disband(id: string): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
|
||||
41
apps/edr-freight-api/src/modules/wagons/train-runs.const.ts
Normal file
41
apps/edr-freight-api/src/modules/wagons/train-runs.const.ts
Normal 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;
|
||||
};
|
||||
@@ -1926,7 +1926,8 @@ export class WarehouseInventoryService {
|
||||
|
||||
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
|
||||
const [schedule] = await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
`SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
@@ -1957,14 +1958,20 @@ export class WarehouseInventoryService {
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
// Only bookings whose destination IS this train's final yard unload into
|
||||
// this (final-destination) warehouse. A mid-corridor import that alighted
|
||||
// at an intermediate yard was already unloaded there by the checkpoint
|
||||
// auto-unload; without this filter it would be mis-located into the final
|
||||
// yard's inventory too.
|
||||
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND b.destination_yard_id = $2`,
|
||||
[scheduleId, schedule.destinationStationId],
|
||||
);
|
||||
|
||||
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
|
||||
|
||||
174
apps/edr-freight-api/src/scripts/update-wagon-runs.ts
Normal file
174
apps/edr-freight-api/src/scripts/update-wagon-runs.ts
Normal 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);
|
||||
});
|
||||
@@ -48,6 +48,14 @@ const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [
|
||||
"Minimum days between today and the vessel departure date on an export Release Order.",
|
||||
multiple: false,
|
||||
},
|
||||
{
|
||||
code: "import_train_numbers",
|
||||
label: "Import train numbers",
|
||||
description:
|
||||
"Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import − 1).",
|
||||
multiple: false,
|
||||
meta: { searchable: true, clearable: true },
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -13,11 +13,13 @@ const INDODE_FACILITY = {
|
||||
facilityType: 'DRY_PORT' as const,
|
||||
facilityStatus: 'ACTIVE' as const,
|
||||
locationName: 'Indode',
|
||||
country: 'Djibouti',
|
||||
city: 'Djibouti',
|
||||
address: 'Indode, Djibouti',
|
||||
latitude: 11.5447,
|
||||
longitude: 43.145,
|
||||
// Indode is the Gelan Multipurpose Port outside Addis — the yard carries it as
|
||||
// KALITY, country Ethiopia. It was seeded as Djibouti, which is the wrong end
|
||||
// of the line. Coordinates are left unset rather than guessed; fill them in
|
||||
// when the real position is to hand.
|
||||
country: 'Ethiopia',
|
||||
city: 'Addis Ababa',
|
||||
address: 'Indode (Gelan), Addis Ababa, Ethiopia',
|
||||
capacity: 50000,
|
||||
isActive: true,
|
||||
notes: 'Primary dry port for container consolidation and distribution',
|
||||
@@ -72,20 +74,22 @@ export class IndodeFacilitySeeder {
|
||||
const yardRepo = manager.getRepository(WarehouseYard);
|
||||
const zoneRepo = manager.getRepository(WarehouseZone);
|
||||
|
||||
// Ensure facility exists
|
||||
const facility = await facilityRepo.findOne({
|
||||
// Ensure facility exists. An existing facility row is not proof the
|
||||
// warehouses under it survived, so reuse it and carry on rather than
|
||||
// returning — otherwise a facility with no warehouses stays that way.
|
||||
const existing = await facilityRepo.findOne({
|
||||
where: { code: INDODE_FACILITY.code },
|
||||
});
|
||||
|
||||
if (facility) {
|
||||
this.logger.log('Indode facility already exists, skipping seed');
|
||||
return;
|
||||
let savedFacility: Facility;
|
||||
if (existing) {
|
||||
savedFacility = await facilityRepo.save({ ...existing, ...INDODE_FACILITY });
|
||||
this.logger.log(`Facility ${savedFacility.code} already exists, reusing`);
|
||||
} else {
|
||||
savedFacility = await facilityRepo.save(facilityRepo.create(INDODE_FACILITY));
|
||||
this.logger.log(`Created facility: ${savedFacility.code}`);
|
||||
}
|
||||
|
||||
const newFacility = facilityRepo.create(INDODE_FACILITY);
|
||||
const savedFacility = await facilityRepo.save(newFacility);
|
||||
this.logger.log(`Created facility: ${savedFacility.code}`);
|
||||
|
||||
// Create warehouses for the facility
|
||||
for (const warehouseData of WAREHOUSES) {
|
||||
try {
|
||||
|
||||
@@ -16,12 +16,19 @@ import { DataSource } from 'typeorm';
|
||||
* Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
|
||||
* settled which is the intercity facility.
|
||||
*/
|
||||
const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [
|
||||
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true },
|
||||
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false },
|
||||
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false },
|
||||
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false },
|
||||
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false },
|
||||
const FACILITY_YARDS: Array<{
|
||||
code: string;
|
||||
facility: string;
|
||||
hasWarehouse: boolean;
|
||||
handlesContainer: boolean;
|
||||
}> = [
|
||||
// Containers need a reach stacker or gantry — only these three are equipped.
|
||||
// Bulk needs far less, so every facility handles it.
|
||||
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true, handlesContainer: true },
|
||||
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false, handlesContainer: true },
|
||||
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false, handlesContainer: true },
|
||||
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false, handlesContainer: false },
|
||||
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false, handlesContainer: false },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
@@ -35,7 +42,7 @@ export class YardFacilitiesSeeder {
|
||||
* yards — a missing code is logged and skipped rather than invented.
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
for (const { code, facility, hasWarehouse } of FACILITY_YARDS) {
|
||||
for (const { code, facility, hasWarehouse, handlesContainer } of FACILITY_YARDS) {
|
||||
const [yard]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
|
||||
[code],
|
||||
@@ -53,11 +60,15 @@ export class YardFacilitiesSeeder {
|
||||
);
|
||||
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes)
|
||||
VALUES ($1, $2, $3)
|
||||
`INSERT INTO freight.yard_facilities
|
||||
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
|
||||
VALUES ($1, $2, $3, true, $4)
|
||||
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
||||
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`,
|
||||
[yard.id, hasWarehouse, `${facility} load/unload facility`],
|
||||
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
|
||||
handles_container = EXCLUDED.handles_container,
|
||||
handles_bulk = EXCLUDED.handles_bulk,
|
||||
updated_at = NOW()`,
|
||||
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
|
||||
@@ -846,6 +846,16 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/clearance-documents/:id"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||
>
|
||||
<ContractClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* GL (Path B) contract clearance review hub */}
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
|
||||
@@ -15,10 +15,23 @@ import {
|
||||
} from "./cookies";
|
||||
import type { AuthTokens } from "./types";
|
||||
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
/**
|
||||
* When true, the response interceptor does NOT raise the global error modal
|
||||
* for this request's failure. For calls the caller handles itself — e.g. a
|
||||
* probe that is expected to 404 before falling back (GL clearance detail
|
||||
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
|
||||
*/
|
||||
suppressErrorModal?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
type RetriableRequest = {
|
||||
_retry?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
url?: string;
|
||||
suppressErrorModal?: boolean;
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
@@ -100,8 +113,13 @@ api.interceptors.response.use(
|
||||
originalRequest.url?.includes("/auth/refresh-token")
|
||||
) {
|
||||
// Surface the server's actual error message in the global error modal
|
||||
// (401s are handled by the session-refresh flow, so skip them).
|
||||
if (error.response && error.response.status !== 401) {
|
||||
// (401s are handled by the session-refresh flow, so skip them). A request
|
||||
// may opt out via `suppressErrorModal` when it handles the failure itself.
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status !== 401 &&
|
||||
!originalRequest?.suppressErrorModal
|
||||
) {
|
||||
const payload = extractApiErrorPayload(error);
|
||||
if (payload) emitApiError(payload);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
|
||||
@@ -94,9 +95,15 @@ export function BookingRequestHero({
|
||||
Booking reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<Stack gap={2} miw={0}>
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<ContractReferenceLink
|
||||
contractId={booking.contractId}
|
||||
contractReference={booking.contractReference}
|
||||
/>
|
||||
</Stack>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
{booking.schedulingStatus ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
@@ -37,10 +38,12 @@ import {
|
||||
FileDown,
|
||||
FileText,
|
||||
FileUp,
|
||||
Flame,
|
||||
MapPin,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
Snowflake,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -128,6 +131,10 @@ interface UnitDraft {
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: string;
|
||||
/** Handling is per physical container; the line counts roll these up. */
|
||||
isHazardous: boolean;
|
||||
isReefer: boolean;
|
||||
isReturn: boolean;
|
||||
}
|
||||
|
||||
/** Mirrors the portal shipment form's container line: line-level quantity +
|
||||
@@ -150,7 +157,14 @@ interface BulkDraft {
|
||||
}
|
||||
|
||||
function emptyUnit(): UnitDraft {
|
||||
return { containerNumber: "", sealNumber: "", vgmTons: "" };
|
||||
return {
|
||||
containerNumber: "",
|
||||
sealNumber: "",
|
||||
vgmTons: "",
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
isReturn: false,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyLine(size: string): ContainerLineDraft {
|
||||
@@ -285,6 +299,41 @@ export default function GlCreateBookingForm() {
|
||||
// Legacy contracts (no equipment return chosen at creation) keep the old
|
||||
// booking-level toggle.
|
||||
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
|
||||
/**
|
||||
* Handling switches offered on each container row — only the services this
|
||||
* contract was created with, since the server rejects the others.
|
||||
*/
|
||||
const handlingColumns = (
|
||||
[
|
||||
contract?.isHazardous && {
|
||||
key: "isHazardous",
|
||||
label: "Hazardous",
|
||||
icon: <Flame size={14} />,
|
||||
color: "#C0392B",
|
||||
},
|
||||
contract?.isReefer && {
|
||||
key: "isReefer",
|
||||
label: "Refrigerated",
|
||||
icon: <Snowflake size={14} />,
|
||||
color: "#2E5B96",
|
||||
},
|
||||
contractWithReturn && {
|
||||
key: "isReturn",
|
||||
label: "With return",
|
||||
icon: <Repeat size={14} />,
|
||||
color: "#0A6F4D",
|
||||
},
|
||||
] as Array<
|
||||
| false
|
||||
| undefined
|
||||
| { key: keyof UnitDraft; label: string; icon: ReactNode; color: string }
|
||||
>
|
||||
).filter(Boolean) as Array<{
|
||||
key: "isHazardous" | "isReefer" | "isReturn";
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
color: string;
|
||||
}>;
|
||||
// Intercity shipments ride a passing import/export train staff pick at
|
||||
// finalize time — no shipment day is chosen and no window gate applies.
|
||||
const isIntercity = contract?.tradeDirection === "DOMESTIC";
|
||||
@@ -358,6 +407,9 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookingRequest || prefilled) return;
|
||||
// A rebook (?copyFrom=) seeds from the expired booking's real cargo —
|
||||
// richer than the request's bare quantities. Let that seed win the race.
|
||||
if (copyFromParam) return;
|
||||
setPrefilled(true);
|
||||
const lines = bookingRequest.requestedLines ?? {};
|
||||
if (lines.containers?.length) {
|
||||
@@ -399,13 +451,27 @@ export default function GlCreateBookingForm() {
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
// Carry the persisted per-unit details (numbers, seals, VGM, handling)
|
||||
// when the source booking has them — a rebooked EXPIRED booking does,
|
||||
// and its cargo is fixed server-side anyway.
|
||||
const units: UnitDraft[] =
|
||||
c.units?.length === qty
|
||||
? c.units.map((u) => ({
|
||||
containerNumber: u.containerNumber ?? "",
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: u.vgmTons != null ? String(u.vgmTons) : "",
|
||||
isHazardous: Boolean(u.isHazardous),
|
||||
isReefer: Boolean(u.isReefer),
|
||||
isReturn: Boolean(u.isReturn),
|
||||
}))
|
||||
: Array.from({ length: qty }, emptyUnit);
|
||||
return {
|
||||
containerSize: String(c.containerType?.sizeFt ?? ""),
|
||||
quantity: String(qty),
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
returnQuantity: "0",
|
||||
units: Array.from({ length: qty }, emptyUnit),
|
||||
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
|
||||
reeferQuantity: String(units.filter((u) => u.isReefer).length),
|
||||
returnQuantity: String(units.filter((u) => u.isReturn).length),
|
||||
units,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -488,6 +554,18 @@ export default function GlCreateBookingForm() {
|
||||
enabled: cargoQuery !== null && !isIntercity,
|
||||
});
|
||||
|
||||
/**
|
||||
* Line handling totals are a roll-up of the per-container switches — the
|
||||
* count is however many containers ticked each service. Recomputed on every
|
||||
* unit change so the price estimate and payload follow the switches.
|
||||
*/
|
||||
const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({
|
||||
...line,
|
||||
hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length),
|
||||
reeferQuantity: String(line.units.filter((u) => u.isReefer).length),
|
||||
returnQuantity: String(line.units.filter((u) => u.isReturn).length),
|
||||
});
|
||||
|
||||
// Keep the units array length in sync with the entered quantity.
|
||||
const syncUnits = (lineIdx: number, qty: number) => {
|
||||
setContainerLines((prev) =>
|
||||
@@ -496,7 +574,7 @@ export default function GlCreateBookingForm() {
|
||||
const next = [...line.units];
|
||||
while (next.length < qty) next.push(emptyUnit());
|
||||
next.length = Math.max(0, qty);
|
||||
return { ...line, units: next };
|
||||
return withDerivedCounts({ ...line, units: next });
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -511,11 +589,16 @@ export default function GlCreateBookingForm() {
|
||||
unitIdx: number,
|
||||
patch: Partial<UnitDraft>,
|
||||
) =>
|
||||
patchLine(lineIdx, {
|
||||
units: containerLines[lineIdx].units.map((u, i) =>
|
||||
i === unitIdx ? { ...u, ...patch } : u,
|
||||
setContainerLines((prev) =>
|
||||
prev.map((l, i) =>
|
||||
i === lineIdx
|
||||
? withDerivedCounts({
|
||||
...l,
|
||||
units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)),
|
||||
})
|
||||
: l,
|
||||
),
|
||||
});
|
||||
);
|
||||
|
||||
// Same client-side validation as the customer portal shipment form
|
||||
// (new-shipment-form/schema.ts): ISO container numbers unique within the
|
||||
@@ -560,10 +643,15 @@ export default function GlCreateBookingForm() {
|
||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||
returnQuantity: String(imported.filter((r) => r.withReturn).length),
|
||||
// The spreadsheet marks handling per row — carry it onto the
|
||||
// container it belongs to rather than collapsing it to a line count.
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
vgmTons: String(r.vgmTons),
|
||||
isHazardous: Boolean(r.hazardous),
|
||||
isReefer: Boolean(r.reefer),
|
||||
isReturn: Boolean(r.withReturn),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
@@ -742,6 +830,11 @@ export default function GlCreateBookingForm() {
|
||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
// Per-container handling — the server rolls these into the line
|
||||
// counts and bills each surcharge on the ticked containers only.
|
||||
isHazardous: Boolean(u.isHazardous),
|
||||
isReefer: Boolean(u.isReefer),
|
||||
...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}),
|
||||
})),
|
||||
}));
|
||||
} else {
|
||||
@@ -1175,78 +1268,60 @@ export default function GlCreateBookingForm() {
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{contract.isHazardous && (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Hazardous qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
error={
|
||||
showErrors
|
||||
? lineErrors[lineIdx]?.hazardousQuantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchLine(lineIdx, {
|
||||
hazardousQuantity: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
{contract.isReefer && (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
value={line.reeferQuantity}
|
||||
error={
|
||||
showErrors
|
||||
? lineErrors[lineIdx]?.reeferQuantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchLine(lineIdx, {
|
||||
reeferQuantity: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
{contractWithReturn && (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="With return qty"
|
||||
description="Containers EDR returns empty"
|
||||
min={0}
|
||||
value={line.returnQuantity}
|
||||
error={
|
||||
showErrors
|
||||
? lineErrors[lineIdx]?.returnQuantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchLine(lineIdx, {
|
||||
returnQuantity: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<StepLabel>Per-container details</StepLabel>
|
||||
{handlingColumns.length > 0 ? (
|
||||
<Text fz={11} c="dimmed" mt={4}>
|
||||
Tick the services each individual container needs —
|
||||
charges apply only to the containers ticked
|
||||
{handlingColumns
|
||||
.map((col) => {
|
||||
const count = line.units.filter(
|
||||
(u) => u[col.key],
|
||||
).length;
|
||||
return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : "";
|
||||
})
|
||||
.join("")}
|
||||
.
|
||||
</Text>
|
||||
) : null}
|
||||
<Stack gap={10} mt={8}>
|
||||
{/* Header row — input labels + handling-service labels,
|
||||
one aligned grid shared by every unit row below.
|
||||
Same layout as the portal shipment form. */}
|
||||
{line.units.length > 0 && (
|
||||
<Group gap={10} wrap="nowrap" align="flex-end">
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
Container number *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
Seal number
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
VGM (tons) *
|
||||
</Text>
|
||||
{handlingColumns.map((col) => (
|
||||
<Group
|
||||
key={col.key}
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
justify="center"
|
||||
style={{ width: 96, flexShrink: 0 }}
|
||||
>
|
||||
<span style={{ color: col.color, display: "flex" }}>
|
||||
{col.icon}
|
||||
</span>
|
||||
<Text fz={12} fw={600} c="#10202F">
|
||||
{col.label}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{line.units.map((unit, unitIdx) => (
|
||||
<Group key={unitIdx} gap={10} grow align="flex-start">
|
||||
<Group key={unitIdx} gap={10} wrap="nowrap" align="flex-start">
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Container number *" : undefined}
|
||||
placeholder="e.g. MSCU1234567"
|
||||
value={unit.containerNumber}
|
||||
error={
|
||||
@@ -1262,9 +1337,9 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Seal number" : undefined}
|
||||
placeholder="Optional"
|
||||
value={unit.sealNumber}
|
||||
onChange={(e) =>
|
||||
@@ -1274,11 +1349,11 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
|
||||
placeholder="e.g. 24.5"
|
||||
min={0}
|
||||
step={0.01}
|
||||
@@ -1295,7 +1370,32 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{handlingColumns.map((col) => (
|
||||
<Box
|
||||
key={col.key}
|
||||
style={{
|
||||
width: 96,
|
||||
flexShrink: 0,
|
||||
height: 42,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
checked={Boolean(unit[col.key])}
|
||||
aria-label={`${col.label} — container ${unitIdx + 1}`}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
[col.key]: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -72,6 +72,7 @@ export type ClearanceViewLike = Pick<
|
||||
| "linkedBookingId"
|
||||
| "riskLevel"
|
||||
| "riskAssignedAt"
|
||||
| "riskHistory"
|
||||
| "secondDuty"
|
||||
| "importReleaseGranted"
|
||||
> & { operationReady?: boolean };
|
||||
@@ -1040,11 +1041,28 @@ function RiskStep({
|
||||
done: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [level, setLevel] = useState<string>("GREEN");
|
||||
const assigned = done || Boolean(clearance.riskLevel);
|
||||
// Duty is advised off the risk level, so once that is done the decision is
|
||||
// final. Until then a mis-assigned level must stay correctable — the server
|
||||
// overwrites the milestone metadata on reassignment. Mirrors AssignRiskCard.
|
||||
const locked = isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED");
|
||||
|
||||
const [level, setLevel] = useState<string>(clearance.riskLevel ?? "GREEN");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (done || clearance.riskLevel) {
|
||||
return (
|
||||
// The clearance view loads (and refetches after a reassignment) after first
|
||||
// render, so mirror the persisted level onto the control whenever it changes —
|
||||
// otherwise reopening the step offers GREEN whatever is actually assigned.
|
||||
useEffect(() => {
|
||||
if (clearance.riskLevel) setLevel(clearance.riskLevel);
|
||||
}, [clearance.riskLevel]);
|
||||
|
||||
// Only the decisions before the current one — the badge above already states
|
||||
// the level in force, so repeating it as a trail entry reads as a duplicate.
|
||||
const priorDecisions = (clearance.riskHistory ?? []).slice(0, -1);
|
||||
|
||||
const assignedSummary = assigned ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm">
|
||||
<Badge
|
||||
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
|
||||
@@ -1061,12 +1079,35 @@ function RiskStep({
|
||||
. The customer can see this level.
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
{priorDecisions.length > 0 ? (
|
||||
<Stack gap={2} pl="xs">
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Previously
|
||||
</Text>
|
||||
{priorDecisions.map((entry, index) => (
|
||||
<Text key={`${entry.assignedAt}-${index}`} size="xs" c="dimmed">
|
||||
{entry.level}
|
||||
{" · "}
|
||||
{new Date(entry.assignedAt).toLocaleString()}
|
||||
{entry.assignedBy ? ` · ${entry.assignedBy}` : ""}
|
||||
{entry.note ? ` · ${entry.note}` : ""}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null;
|
||||
|
||||
// Assigned and final: the badge is all that is left to show.
|
||||
if (assigned && (locked || !canAct || !bookingId)) {
|
||||
return assignedSummary;
|
||||
}
|
||||
|
||||
// Customs cannot rate cargo still under transit — the server rejects the
|
||||
// assignment until the T1 is closed, so do not offer the control yet.
|
||||
if (!clearance.t1?.closed) {
|
||||
// assignment until the T1 is closed, so do not offer the control yet. Skipped
|
||||
// once a level exists: risk cannot have been assigned without a closed T1, so
|
||||
// a still-open T1 here is stale data and must not hide the assigned badge.
|
||||
if (!assigned && !clearance.t1?.closed) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
@@ -1088,6 +1129,7 @@ function RiskStep({
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{assignedSummary}
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={level}
|
||||
@@ -1100,19 +1142,24 @@ function RiskStep({
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
The customer sees the assigned risk level.
|
||||
{assigned
|
||||
? "Correctable until duty is advised. The customer sees the assigned risk level."
|
||||
: "The customer sees the assigned risk level."}
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={assigned && level === clearance.riskLevel}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.assignRisk(bookingId, {
|
||||
riskLevel: level as Freight.CustomsRiskLevel,
|
||||
});
|
||||
toast.success("Customs risk assigned");
|
||||
toast.success(
|
||||
assigned ? "Customs risk reassigned" : "Customs risk assigned",
|
||||
);
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
@@ -1121,7 +1168,7 @@ function RiskStep({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Assign risk
|
||||
{assigned ? "Reassign risk" : "Assign risk"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -104,6 +104,11 @@ export function ApiErrorModal() {
|
||||
onClose={close}
|
||||
centered
|
||||
radius="md"
|
||||
// Mounted at the app root, so its portal is FIRST in <body> — at the
|
||||
// default z-index (200) any page modal opened later (create schedule,
|
||||
// allocation wizard, …) paints over it and the error hides underneath.
|
||||
// Hoist above every Mantine overlay and the react-hot-toast layer (9999).
|
||||
zIndex={10000}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />
|
||||
|
||||
@@ -16,7 +16,8 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { IMPORT_TRAIN_OPTIONS, exportRunFor } from "@/constants/trainRuns";
|
||||
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
|
||||
import { exportRunFor } from "@/constants/trainRuns";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -42,6 +43,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
// Admin-managed run list (dropdown settings); numbers already on a train
|
||||
// come back disabled so they cannot be picked twice.
|
||||
const importNumbers = useImportTrainNumberOptions();
|
||||
// Only serviceable locomotives standing in the selected yard can be coupled.
|
||||
const locomotivesQuery = useQuery(
|
||||
api.locomotives.listFiltered.queryOptions({
|
||||
@@ -150,12 +154,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
<Select
|
||||
label="Import train number"
|
||||
description="Even — Djibouti → Ethiopia runs"
|
||||
placeholder="e.g. 8002"
|
||||
data={IMPORT_TRAIN_OPTIONS}
|
||||
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
|
||||
data={importNumbers.options}
|
||||
value={importTrainNumber || null}
|
||||
onChange={(value) => setImportTrainNumber(value ?? "")}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { exportRunFor } from "@/constants/trainRuns";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
|
||||
import { api } from "@/services/api";
|
||||
import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
|
||||
|
||||
@@ -15,9 +17,11 @@ export interface EditTrainDetailsModalProps {
|
||||
|
||||
/**
|
||||
* Edit a built train's display identity from the list: its name and its fixed
|
||||
* import/export run numbers. Composition (yard, locomotives, wagons) is edited
|
||||
* on the detail page. Number collisions come back as a 409 with the owning
|
||||
* train's code and surface verbatim.
|
||||
* import/export run numbers. The import number comes from the admin-managed
|
||||
* dropdown setting (numbers on other trains are disabled; this train's own
|
||||
* number stays pickable) and the export number follows it. Composition (yard,
|
||||
* locomotives, wagons) is edited on the detail page. Number collisions come
|
||||
* back as a 409 with the owning train's code and surface verbatim.
|
||||
*/
|
||||
const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
|
||||
const { toast } = useToast();
|
||||
@@ -25,6 +29,8 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
|
||||
const [importNo, setImportNo] = useState("");
|
||||
const [exportNo, setExportNo] = useState("");
|
||||
|
||||
const importNumbers = useImportTrainNumberOptions(train?.importTrainNumber);
|
||||
|
||||
useEffect(() => {
|
||||
if (train) {
|
||||
setName(train.trainName ?? "");
|
||||
@@ -86,20 +92,29 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
|
||||
radius="md"
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
<Select
|
||||
label="Import train no."
|
||||
placeholder="e.g. 8002"
|
||||
value={importNo}
|
||||
onChange={(e) => setImportNo(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
|
||||
data={importNumbers.options}
|
||||
value={importNo || null}
|
||||
onChange={(value) => {
|
||||
// Clearing keeps the stored numbers (empty inputs are dropped on
|
||||
// save); a pick re-derives the paired export run.
|
||||
setImportNo(value ?? "");
|
||||
setExportNo(value ? exportRunFor(value) : (train?.exportTrainNumber ?? ""));
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
|
||||
radius="md"
|
||||
/>
|
||||
<TextInput
|
||||
label="Export train no."
|
||||
description="Follows the import run"
|
||||
placeholder="e.g. 8001"
|
||||
value={exportNo}
|
||||
onChange={(e) => setExportNo(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
readOnly
|
||||
variant="filled"
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -15,6 +15,8 @@ export const trainStatusColor = (status: BuiltTrainStatus | string): string => {
|
||||
return "yellow";
|
||||
case "OUT_OF_SERVICE":
|
||||
return "red";
|
||||
case "DEACTIVATED":
|
||||
return "gray";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
|
||||
@@ -40,17 +40,21 @@ export function PreviewSummary({
|
||||
summary?: {
|
||||
totalBookings: number;
|
||||
totalWeightTons: number;
|
||||
grossWeightTons?: number;
|
||||
totalTareTons?: number;
|
||||
wagonType: string;
|
||||
wagonsNeeded: number;
|
||||
totalLengthMeters: number;
|
||||
};
|
||||
}) {
|
||||
if (!summary) return null;
|
||||
// GROSS — the axis every train limit is spent against.
|
||||
const gross = summary.grossWeightTons ?? summary.totalWeightTons;
|
||||
const stats = [
|
||||
{ label: "Bookings", value: String(summary.totalBookings) },
|
||||
{ label: "Wagons", value: String(summary.wagonsNeeded) },
|
||||
{ label: "Wagon type", value: summary.wagonType },
|
||||
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
|
||||
{ label: "Gross weight", value: `${gross}T` },
|
||||
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
|
||||
];
|
||||
return (
|
||||
|
||||
@@ -99,9 +99,8 @@ function usedWeight(schedule: TrainScheduleDetail): number {
|
||||
/**
|
||||
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
|
||||
* unknown). The API caps at the weakest loco, not the sum of all locos — a
|
||||
* consist can only pull as hard as its weakest engine. Note: the API also adds
|
||||
* the consist tare to the used weight when it checks this cap; tare isn't
|
||||
* available client-side, so this meter compares cargo-only load against pull.
|
||||
* consist can only pull as hard as its weakest engine. Both sides of this meter
|
||||
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
|
||||
*/
|
||||
function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
const set = schedule.trainSet;
|
||||
|
||||
@@ -46,6 +46,8 @@ type NormalizedWagon = {
|
||||
|
||||
const CAR_WIDTH = 150; // car body + coupler footprint
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
|
||||
const allocations = w.allocations ?? [];
|
||||
const firstLoad = (
|
||||
@@ -268,10 +270,11 @@ const CONTAINER_BORDERS = [
|
||||
];
|
||||
|
||||
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
|
||||
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
|
||||
const utilization =
|
||||
wagon.capacityTons > 0
|
||||
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
|
||||
: 0;
|
||||
maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0;
|
||||
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
@@ -281,8 +284,8 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
||||
}${
|
||||
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
|
||||
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${
|
||||
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
|
||||
}`;
|
||||
|
||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
||||
@@ -369,7 +372,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
/>
|
||||
</Box>
|
||||
<Text size="9px" c="dimmed" ta="center" fw={600}>
|
||||
{wagon.assignedWeightTons}/{wagon.capacityTons}T
|
||||
{grossTons}/{maxGrossTons}T
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
@@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
</Text>
|
||||
{!wagon.isEmpty ? (
|
||||
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
|
||||
{wagon.assignedWeightTons}T
|
||||
{grossTons}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
@@ -6,6 +6,7 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
tareWeightTons?: number | null;
|
||||
slotLoadType?: string;
|
||||
wagonType?: { code: string } | null;
|
||||
wagonTypeCode?: string;
|
||||
@@ -21,6 +22,8 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
|
||||
}>;
|
||||
};
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
|
||||
const normalized = loadType?.toUpperCase() ?? "";
|
||||
if (normalized.includes("BULK")) return "orange";
|
||||
@@ -68,8 +71,16 @@ export function WagonPlanGrid({
|
||||
);
|
||||
}
|
||||
|
||||
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
|
||||
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const totalTare = round1(
|
||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||
);
|
||||
const totalCapacity = round1(
|
||||
wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0) + totalTare,
|
||||
);
|
||||
const totalAssigned = round1(
|
||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0) + totalTare,
|
||||
);
|
||||
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
|
||||
|
||||
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
|
||||
@@ -82,7 +93,7 @@ export function WagonPlanGrid({
|
||||
</Text>
|
||||
{isBulk ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
|
||||
Gross: <strong>{totalAssigned}</strong> / {totalCapacity}T
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
@@ -90,8 +101,9 @@ export function WagonPlanGrid({
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
|
||||
{wagonPlan.map((wagon) => {
|
||||
const seq = wagon.sequenceNo;
|
||||
const capacity = wagon.capacityTons;
|
||||
const assigned = wagon.assignedWeightTons;
|
||||
const tare = Number(wagon.tareWeightTons) || 0;
|
||||
const capacity = round1(wagon.capacityTons + tare);
|
||||
const assigned = round1(wagon.assignedWeightTons + tare);
|
||||
const allocations = wagon.allocations ?? [];
|
||||
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
|
||||
const label = slotLabel(wagon, freightType);
|
||||
@@ -149,7 +161,7 @@ export function WagonPlanGrid({
|
||||
</Text>
|
||||
{label === "BULK" ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{alloc.allocatedWeightTons}T
|
||||
{alloc.allocatedWeightTons}T cargo
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
@@ -132,7 +132,7 @@ export const BookingDetailModal = ({
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<Weight size={15} />}
|
||||
label="Weight"
|
||||
label="Gross weight"
|
||||
value={
|
||||
<Text size="sm" fw={700}>
|
||||
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}
|
||||
|
||||
@@ -161,8 +161,11 @@ function WagonCar({
|
||||
const allocation = wagon.allocations?.[0];
|
||||
const isEmpty = !allocation;
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0;
|
||||
const capacity = wagon.capacityTons ?? 0;
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const tare = wagon.tareWeightTons ?? 0;
|
||||
const assigned =
|
||||
(allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare;
|
||||
const capacity = (wagon.capacityTons ?? 0) + tare;
|
||||
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
|
||||
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
@@ -21,6 +21,8 @@ export const RemoveBookingModal = ({
|
||||
if (!wagon || !wagon.allocations?.[0]) return null;
|
||||
|
||||
const allocation = wagon.allocations[0];
|
||||
// GROSS: allocated cargo + the tare of the wagon it sits on.
|
||||
const grossTons = (allocation.allocatedWeightTons ?? 0) + (wagon.tareWeightTons ?? 0);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
|
||||
@@ -40,7 +42,7 @@ export const RemoveBookingModal = ({
|
||||
</Badge>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
|
||||
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
||||
|
||||
@@ -93,10 +93,14 @@ export const TrainConsistView = ({
|
||||
}
|
||||
};
|
||||
|
||||
const weightUsed = wagons.reduce(
|
||||
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0),
|
||||
// GROSS: cargo on every allocation + the tare of every wagon in the consist.
|
||||
// maxPullWeightTons is a gross limit, so the numerator must be gross too.
|
||||
const cargoUsed = wagons.reduce(
|
||||
(sum, w) => sum + (w.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
|
||||
0,
|
||||
);
|
||||
const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0);
|
||||
const weightUsed = cargoUsed + tareUsed;
|
||||
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
|
||||
|
||||
return (
|
||||
|
||||
@@ -98,7 +98,7 @@ export const TrainStatsBar = ({
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
|
||||
<StatTile
|
||||
icon={<Weight size={15} />}
|
||||
label="Weight"
|
||||
label="Gross weight"
|
||||
pct={weightPct}
|
||||
current={weightUsed.toFixed(1)}
|
||||
max={weightMax?.toFixed(1) ?? "∞"}
|
||||
|
||||
@@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({
|
||||
|
||||
{bookings.map((booking) => {
|
||||
const isActive = selectedBookingId === booking.id;
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
// GROSS (cargo + wagon tare) so this badge shares the axis every other
|
||||
// weight on the page uses — cargo-only here read ~25% light.
|
||||
const weight = Number(booking.grossWeightTons ?? booking.cargoTotalWeightVgm ?? 0);
|
||||
const fits = booking.canAssign;
|
||||
const blockReason = booking.blockReason;
|
||||
|
||||
|
||||
@@ -36,8 +36,12 @@ export const WagonCard = ({
|
||||
const hasAllocations = Boolean(allocation);
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
|
||||
const weightUsed = allocation?.allocatedWeightTons ?? 0;
|
||||
const weightMax = wagon.capacityTons ?? 0;
|
||||
// GROSS on both sides: loaded cargo + wagon tare, against the wagon's max
|
||||
// gross (rated payload + tare). Keeps the wagon axis identical to the train
|
||||
// axis in TrainStatsBar.
|
||||
const tare = wagon.tareWeightTons ?? 0;
|
||||
const weightUsed = (allocation?.allocatedWeightTons ?? 0) + tare;
|
||||
const weightMax = (wagon.capacityTons ?? 0) + tare;
|
||||
const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
|
||||
|
||||
const wagonType = wagon.wagonType?.code || "UNKNOWN";
|
||||
|
||||
@@ -54,10 +54,26 @@ export const TRAIN_RUN_FILTER_OPTIONS = Object.entries(TRAIN_RUN_PAIRS).map(
|
||||
}),
|
||||
);
|
||||
|
||||
/** The import run implied by an export run; empty string when unset/unknown. */
|
||||
export const importRunFor = (exportRun: unknown): string =>
|
||||
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? "";
|
||||
/**
|
||||
* The import run implied by an export run; empty string when unset/unknown.
|
||||
* Runs outside the hardcoded pairs (admin-added via dropdown settings) fall
|
||||
* back to the numeric convention: import = export + 1.
|
||||
*/
|
||||
export const importRunFor = (exportRun: unknown): string => {
|
||||
const run = String(exportRun ?? "");
|
||||
const paired = TRAIN_RUN_PAIRS[run];
|
||||
if (paired) return paired;
|
||||
return /^\d*[13579]$/.test(run) ? String(Number(run) + 1) : "";
|
||||
};
|
||||
|
||||
/** The export run implied by an import run; empty string when unset/unknown. */
|
||||
export const exportRunFor = (importRun: unknown): string =>
|
||||
EXPORT_BY_IMPORT[String(importRun ?? "")] ?? "";
|
||||
/**
|
||||
* The export run implied by an import run; empty string when unset/unknown.
|
||||
* Runs outside the hardcoded pairs (admin-added via dropdown settings) fall
|
||||
* back to the numeric convention: export = import − 1.
|
||||
*/
|
||||
export const exportRunFor = (importRun: unknown): string => {
|
||||
const run = String(importRun ?? "");
|
||||
const paired = EXPORT_BY_IMPORT[run];
|
||||
if (paired) return paired;
|
||||
return /^\d*[02468]$/.test(run) && Number(run) > 0 ? String(Number(run) - 1) : "";
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
contractReference: booking.contractReference ?? null,
|
||||
contractId: booking.contractId ?? null,
|
||||
approvalSteps: booking.approvalSteps,
|
||||
customerLabel: booking.isGovernment
|
||||
? (booking.governmentInstitution ?? "Government")
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,8 @@ export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
// staleTime: 30_000,
|
||||
staleTime:0,
|
||||
// Data freshness is driven by mutation invalidation (MutationCache above),
|
||||
// socket pushes, and explicit polling — not by tab focus. Focus refetch
|
||||
// just re-fires every mounted query each time the window is refocused.
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
useBookingList,
|
||||
useBookingListSummary,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
@@ -308,7 +309,17 @@ export default function BookingRequestsPage() {
|
||||
return (
|
||||
<div className="py-1">
|
||||
{ref ? (
|
||||
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
|
||||
// Fall back to plain text when the id is missing — the reference is
|
||||
// still worth showing, it just has nowhere to link to.
|
||||
(row.original.contractId ? (
|
||||
<ContractReferenceLink
|
||||
contractId={row.original.contractId}
|
||||
contractReference={ref}
|
||||
className="truncate font-mono text-xs text-foreground underline underline-offset-2 hover:text-primary"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Clock,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -110,6 +111,16 @@ export default function DocumentClearanceDetailPage() {
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
// The completed booking expired unpaid at train dispatch. Its per-booking
|
||||
// clearance is finished, so GL rebooks it onto a new day — the customer never
|
||||
// re-requests the shipment or pays the clearance fee again.
|
||||
const canRebookExpired =
|
||||
booking?.status === "EXPIRED" &&
|
||||
Boolean(booking?.contractId) &&
|
||||
Number(booking?.totalAmount ?? 0) > 0 &&
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
@@ -194,6 +205,19 @@ export default function DocumentClearanceDetailPage() {
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : canRebookExpired ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<RotateCcw size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete?copyFrom=${id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Rebook shipment
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -452,7 +452,9 @@ export default function ClearanceDocumentsPage() {
|
||||
data={contractRows}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/contracts/clearance/${row.id}`)
|
||||
navigate(
|
||||
`/dashboard/contracts/clearance-documents/${row.id}`,
|
||||
)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -52,9 +52,21 @@ import {
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { pathname } = useLocation();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { user } = useAuth();
|
||||
|
||||
// The same detail page serves two hubs: the GL "Document Clearance" list and
|
||||
// the Operations "Clearance Documents" list. Point back-navigation at
|
||||
// whichever hub the user came through.
|
||||
const fromOpsHub = pathname.startsWith(
|
||||
"/dashboard/contracts/clearance-documents",
|
||||
);
|
||||
const hubHref = fromOpsHub
|
||||
? "/dashboard/contracts/clearance-documents"
|
||||
: "/dashboard/contracts/clearance";
|
||||
const hubLabel = fromOpsHub ? "Clearance Documents" : "Document Clearance";
|
||||
|
||||
const { data: contract, refetch: refetchContract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
@@ -153,12 +165,9 @@ export default function ContractClearanceDetailPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance not found"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
backTo={hubHref}
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: hubLabel, href: hubHref },
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
@@ -176,12 +185,9 @@ export default function ContractClearanceDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
backTo={hubHref}
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: hubLabel, href: hubHref },
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
|
||||
@@ -197,6 +197,29 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
// Terminal contracts stay listed as history — badge the terminal state
|
||||
// instead of falling through to "Under review".
|
||||
if (["EXPIRED", "CANCELLED", "REJECTED"].includes(row.status)) {
|
||||
return (
|
||||
<Tooltip
|
||||
label="This contract is no longer active — kept here for clearance history."
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={row.status === "EXPIRED" ? "orange" : "red"}
|
||||
radius="sm"
|
||||
>
|
||||
{row.status === "EXPIRED"
|
||||
? "Contract expired"
|
||||
: row.status === "CANCELLED"
|
||||
? "Cancelled"
|
||||
: "Rejected"}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (row.paymentExpired) {
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -727,8 +750,12 @@ export default function ContractClearanceListPage() {
|
||||
)
|
||||
}
|
||||
onRebook={(row) =>
|
||||
// Re-complete the SAME expired booking (new day, same finished
|
||||
// per-booking clearance) — a fresh create-booking would spawn a
|
||||
// new instance and force the customer through clearance + fee
|
||||
// again.
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
|
||||
)
|
||||
}
|
||||
onViewContract={(contractId) =>
|
||||
@@ -813,6 +840,17 @@ const shipmentStatusColor = (s: string) => {
|
||||
if (s === "AWAITING_DOCUMENTS") return "yellow";
|
||||
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
|
||||
if (s === "CLEARANCE_READY") return "edr-green";
|
||||
if (
|
||||
[
|
||||
"SELECTED_FOR_BATCH",
|
||||
"PNR_GENERATED",
|
||||
"AWAITING_PAYMENT",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
].includes(s)
|
||||
)
|
||||
return "violet";
|
||||
if (s === "EXPIRED") return "orange";
|
||||
if (s === "CANCELLED" || s === "REJECTED") return "red";
|
||||
return "gray";
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
FilterX,
|
||||
Inbox,
|
||||
LayoutList,
|
||||
RefreshCw,
|
||||
@@ -36,7 +40,10 @@ import {
|
||||
} from "@/components/contracts/ContractStatusTabs";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
CONTRACT_LIST_TABS,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
} from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
getStaffRowAction,
|
||||
toContractListRow,
|
||||
@@ -61,6 +68,63 @@ function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
|
||||
return match.statuses.join(",");
|
||||
}
|
||||
|
||||
/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */
|
||||
function getStatusOptionsForTab(
|
||||
tab: ContractStatusTabKey,
|
||||
): { value: string; label: string }[] {
|
||||
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
||||
const statuses = match?.statuses?.length
|
||||
? match.statuses
|
||||
: CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []);
|
||||
return statuses.map((s) => ({
|
||||
value: s,
|
||||
label: CONTRACT_STATUS_STYLES[s]?.label ?? s,
|
||||
}));
|
||||
}
|
||||
|
||||
const TRADE_DIRECTION_OPTIONS = [
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
{ value: "DOMESTIC", label: "Domestic" },
|
||||
];
|
||||
|
||||
const FREIGHT_TYPE_OPTIONS = [
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
];
|
||||
|
||||
const CONTRACT_KIND_OPTIONS = [
|
||||
{ value: "GENERAL", label: "General (recurring)" },
|
||||
{ value: "ONE_TIME", label: "One-time" },
|
||||
];
|
||||
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ value: "ETB", label: "ETB" },
|
||||
{ value: "USD", label: "USD" },
|
||||
];
|
||||
|
||||
/** value = `${sortBy}:${sortOrder}` for the sort Select. */
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "createdAt:DESC", label: "Newest first" },
|
||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||
{ value: "contractValidUntil:ASC", label: "Expiring soonest" },
|
||||
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
|
||||
];
|
||||
|
||||
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
||||
function startOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
/** Local end-of-day → ISO, for inclusive "to" date filters. */
|
||||
function endOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
@@ -79,32 +143,86 @@ export default function ContractRequestsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||
// Filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [kindFilter, setKindFilter] = useState<string | null>(null);
|
||||
const [currencyFilter, setCurrencyFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
const statusOptions = useMemo(
|
||||
() => getStatusOptionsForTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}, [setPagination, pagination.pageSize]);
|
||||
|
||||
const filter: ContractListFilter = useMemo(
|
||||
() => ({
|
||||
const filter: ContractListFilter = useMemo(() => {
|
||||
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
sortBy,
|
||||
sortOrder,
|
||||
tab: activeTab,
|
||||
// Server-side free-text search (contract reference, customer name).
|
||||
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
||||
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
activeTab,
|
||||
tabStatuses,
|
||||
debouncedQuery,
|
||||
],
|
||||
);
|
||||
// Explicit status picks narrow within the tab; otherwise the tab's
|
||||
// status group applies.
|
||||
...(statusFilter.length
|
||||
? { statuses: statusFilter.join(",") }
|
||||
: tabStatuses
|
||||
? { statuses: tabStatuses }
|
||||
: {}),
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
...(kindFilter ? { contractKind: kindFilter } : {}),
|
||||
...(currencyFilter ? { paymentCurrency: currencyFilter } : {}),
|
||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
||||
};
|
||||
}, [
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
activeTab,
|
||||
tabStatuses,
|
||||
debouncedQuery,
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
kindFilter,
|
||||
currencyFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
sort,
|
||||
]);
|
||||
|
||||
const activeFilterCount =
|
||||
(statusFilter.length ? 1 : 0) +
|
||||
(directionFilter ? 1 : 0) +
|
||||
(freightTypeFilter ? 1 : 0) +
|
||||
(kindFilter ? 1 : 0) +
|
||||
(currencyFilter ? 1 : 0) +
|
||||
(createdFrom || createdTo ? 1 : 0);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setStatusFilter([]);
|
||||
setDirectionFilter(null);
|
||||
setFreightTypeFilter(null);
|
||||
setKindFilter(null);
|
||||
setCurrencyFilter(null);
|
||||
setCreatedFrom(null);
|
||||
setCreatedTo(null);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}, [setPagination, pagination.pageSize]);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } =
|
||||
useContractList(filter);
|
||||
@@ -341,6 +459,8 @@ export default function ContractRequestsPage() {
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
setActiveTab(tab);
|
||||
// Status picks belong to the previous tab's option set — reset.
|
||||
setStatusFilter([]);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
counts={tabCounts}
|
||||
@@ -349,38 +469,159 @@ export default function ContractRequestsPage() {
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
resetPage();
|
||||
}}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Select
|
||||
data={SORT_OPTIONS}
|
||||
value={sort}
|
||||
onChange={(v) => {
|
||||
setSort(v ?? "createdAt:DESC");
|
||||
resetPage();
|
||||
}}
|
||||
allowDeselect={false}
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
aria-label="Sort contracts"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<MultiSelect
|
||||
placeholder={
|
||||
statusFilter.length ? undefined : "All statuses"
|
||||
}
|
||||
data={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 220 }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 150 }}
|
||||
aria-label="Filter by trade direction"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All freight types"
|
||||
data={FREIGHT_TYPE_OPTIONS}
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
aria-label="Filter by freight type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All kinds"
|
||||
data={CONTRACT_KIND_OPTIONS}
|
||||
value={kindFilter}
|
||||
onChange={(v) => {
|
||||
setKindFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 160 }}
|
||||
aria-label="Filter by contract kind"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All currencies"
|
||||
data={CURRENCY_OPTIONS}
|
||||
value={currencyFilter}
|
||||
onChange={(v) => {
|
||||
setCurrencyFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Filter by payment currency"
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created from"
|
||||
value={createdFrom}
|
||||
onChange={(v) => {
|
||||
setCreatedFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={createdTo ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Created from"
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created to"
|
||||
value={createdTo}
|
||||
onChange={(v) => {
|
||||
setCreatedTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={createdFrom ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
aria-label="Created to"
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{showEmpty ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
PackagePlus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -61,9 +60,12 @@ type GlClearanceDetail =
|
||||
|
||||
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
try {
|
||||
// Probe the contract endpoints first; a booking-id row 404s here by design
|
||||
// and falls back to the booking lookup below. Suppress the global error
|
||||
// modal so that expected 404 never surfaces to the user.
|
||||
const [clearance, contract] = await Promise.all([
|
||||
contractsService.getClearance(id),
|
||||
contractsService.getById(id),
|
||||
contractsService.getClearance(id, { suppressErrorModal: true }),
|
||||
contractsService.getById(id, { suppressErrorModal: true }),
|
||||
]);
|
||||
return {
|
||||
kind: "contract",
|
||||
@@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
||||
export default function GlClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
||||
@@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() {
|
||||
{hasRo ? "Replace RO" : "Upload RO"}
|
||||
</Button>
|
||||
)}
|
||||
{canCompleteBooking && shipmentBooking ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -106,6 +106,18 @@ function statusColor(status: string): string {
|
||||
case "ACTIVE_SHIPMENT_IN_PROGRESS":
|
||||
case "IN_TRANSIT":
|
||||
return "teal";
|
||||
// Payment phase — booking selected / awaiting the customer's payment.
|
||||
case "SELECTED_FOR_BATCH":
|
||||
case "PNR_GENERATED":
|
||||
case "AWAITING_PAYMENT":
|
||||
case "PAYMENT_VERIFICATION_IN_PROGRESS":
|
||||
return "violet";
|
||||
// Terminal rows kept as clearance history.
|
||||
case "EXPIRED":
|
||||
return "orange";
|
||||
case "CANCELLED":
|
||||
case "REJECTED":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
CalendarClock,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
Power,
|
||||
PowerOff,
|
||||
Replace,
|
||||
Ruler,
|
||||
Trash2,
|
||||
@@ -70,6 +72,7 @@ export default function TrainBuilderDetailPage() {
|
||||
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
||||
@@ -81,6 +84,8 @@ export default function TrainBuilderDetailPage() {
|
||||
);
|
||||
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
|
||||
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
||||
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
|
||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||
|
||||
const composition = compositionQuery.data;
|
||||
const busy =
|
||||
@@ -172,6 +177,27 @@ export default function TrainBuilderDetailPage() {
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
{composition.status === "DEACTIVATED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Power size={15} />}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await activate.mutateAsync(composition.id);
|
||||
toast({ title: `Train ${composition.code} reactivated` });
|
||||
}, "Could not reactivate train")
|
||||
}
|
||||
>
|
||||
Reactivate train
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
leftSection={<PowerOff size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDeactivateOpen(true)}
|
||||
>
|
||||
Deactivate train
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
@@ -356,6 +382,39 @@ export default function TrainBuilderDetailPage() {
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={deactivateOpen}
|
||||
onClose={() => setDeactivateOpen(false)}
|
||||
title={<Text fw={600}>Deactivate train {composition.code}?</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
The train is parked and cannot be picked for new schedules until it is
|
||||
reactivated. Its locomotives and wagons stay coupled.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setDeactivateOpen(false)}>
|
||||
Keep active
|
||||
</Button>
|
||||
<Button
|
||||
color="gray"
|
||||
loading={deactivate.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await deactivate.mutateAsync(composition.id);
|
||||
toast({ title: `Train ${composition.code} deactivated` });
|
||||
setDeactivateOpen(false);
|
||||
}, "Could not deactivate train")
|
||||
}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={disbandOpen}
|
||||
onClose={() => setDisbandOpen(false)}
|
||||
|
||||
@@ -315,6 +315,7 @@ export default function TrainBuilderListPage() {
|
||||
{ value: "IN_SERVICE", label: "In service" },
|
||||
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
|
||||
{ value: "OUT_OF_SERVICE", label: "Out of service" },
|
||||
{ value: "DEACTIVATED", label: "Deactivated" },
|
||||
]}
|
||||
w={180}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
|
||||
@@ -66,8 +66,8 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardCounts,
|
||||
BatchBoardScheduleDetail,
|
||||
BatchWindowGroup,
|
||||
BookingAllocationStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
@@ -398,7 +398,7 @@ const BookingTable = memo(function BookingTable({
|
||||
);
|
||||
});
|
||||
|
||||
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
|
||||
function WindowCountChips({ counts }: { counts: BatchBoardCounts }) {
|
||||
const chips: Array<{ value: number; color: string; label: string }> = [
|
||||
{ value: counts.allocated, color: "edr-green", label: "allocated" },
|
||||
{ value: counts.selectedForBatch, color: "orange", label: "selected" },
|
||||
@@ -609,9 +609,7 @@ export default function BatchScheduleDetailPage() {
|
||||
const hasAssignedWagons = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
data?.windows.some((w) =>
|
||||
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
) ||
|
||||
data?.bookings.some((b) => b.allocationStatus === "ASSIGNED") ||
|
||||
data?.pendingContract.bookings.some(
|
||||
(b) => b.allocationStatus === "ASSIGNED",
|
||||
),
|
||||
@@ -619,36 +617,33 @@ export default function BatchScheduleDetailPage() {
|
||||
[data],
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
|
||||
const scheduleDetailQuery = useQuery(
|
||||
api.trainScheduling.scheduleDetail.queryOptions({
|
||||
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// The heavy composition graph is only rendered by the composition tab and
|
||||
// the overview diagram (which needs assigned wagons) — don't fetch it
|
||||
// until one of them can actually show something.
|
||||
enabled:
|
||||
Boolean(scheduleId) &&
|
||||
(hasAssignedWagons || activeTab === "composition"),
|
||||
// Composition data only changes through mutations, which invalidate the
|
||||
// whole train-scheduling root — no need to refetch on remounts in between.
|
||||
staleTime: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
// Every booking on this schedule, flattened across windows + pending-contract,
|
||||
// de-duplicated (a booking only appears once). Feeds the management table.
|
||||
// Every booking on this schedule: in-window + pending-contract (the two
|
||||
// buckets are disjoint). Feeds the management table.
|
||||
const allBookings = useMemo(() => {
|
||||
if (!data) return [] as BatchBoardBookingDetail[];
|
||||
const merged = [
|
||||
...data.windows.flatMap((w) => w.bookings),
|
||||
...data.pendingContract.bookings,
|
||||
];
|
||||
const byId = new Map<string, BatchBoardBookingDetail>();
|
||||
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
|
||||
return [...byId.values()];
|
||||
return [...data.bookings, ...data.pendingContract.bookings];
|
||||
}, [data]);
|
||||
|
||||
// All bookings that fall inside the schedule's booking window (every window
|
||||
// cycle, flattened) — the window is one booking day, so these belong to the
|
||||
// single window panel above.
|
||||
const windowBookings = useMemo(
|
||||
() => (data?.windows ?? []).flatMap((w) => w.bookings),
|
||||
[data?.windows],
|
||||
);
|
||||
// Bookings inside the schedule's booking window — they belong to the single
|
||||
// window panel above.
|
||||
const windowBookings = data?.bookings ?? [];
|
||||
|
||||
const windowCounts = useMemo(() => {
|
||||
const counts = {
|
||||
@@ -684,7 +679,6 @@ export default function BatchScheduleDetailPage() {
|
||||
[data?.status],
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||
null,
|
||||
|
||||
@@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) =>
|
||||
const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT";
|
||||
const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED";
|
||||
|
||||
/** Yards with no equipment can never load/unload — surface it before the train arrives. */
|
||||
function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) {
|
||||
/**
|
||||
* A yard that can't handle THIS booking's cargo can never work it — surface that
|
||||
* while the train is still coming, not when the load is refused. Containers need
|
||||
* a facility with a stacker (Indode, Modjo, Dire Dawa); bulk is handled at all of
|
||||
* them.
|
||||
*/
|
||||
function FacilityCell({
|
||||
yard,
|
||||
has,
|
||||
freightType,
|
||||
}: {
|
||||
yard: string | null;
|
||||
has: boolean | null;
|
||||
freightType: string | null;
|
||||
}) {
|
||||
if (!yard) return <Text size="sm">—</Text>;
|
||||
if (has) return <Text size="sm">{yard}</Text>;
|
||||
return (
|
||||
<Tooltip
|
||||
label="This yard has no load/unload facility — cargo cannot be handled here"
|
||||
label={`${yard} cannot handle ${(freightType ?? "this").toLowerCase()} cargo — no facility here, or no equipment for it`}
|
||||
withArrow
|
||||
multiline
|
||||
w={240}
|
||||
w={260}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<AlertTriangle size={13} color="var(--mantine-color-red-6)" />
|
||||
@@ -95,7 +108,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Table.Td>{r.customer ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FacilityCell yard={r.origin} has={r.originHasFacility} />
|
||||
<FacilityCell yard={r.origin} has={r.originHasFacility} freightType={r.freightType} />
|
||||
{atOrigin(r) && isWaiting(r) && (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
train here
|
||||
@@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FacilityCell yard={r.destination} has={r.destinationHasFacility} />
|
||||
<FacilityCell yard={r.destination} has={r.destinationHasFacility} freightType={r.freightType} />
|
||||
{atDestination(r) && isRiding(r) && (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
train here
|
||||
@@ -215,7 +228,7 @@ export default function IntercityPage() {
|
||||
<Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} />
|
||||
<Stat
|
||||
icon={<AlertTriangle size={18} />}
|
||||
label="No facility"
|
||||
label="Cannot handle"
|
||||
value={blocked.length}
|
||||
color={blocked.length > 0 ? "red" : undefined}
|
||||
/>
|
||||
@@ -229,8 +242,9 @@ export default function IntercityPage() {
|
||||
title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`}
|
||||
mb="md"
|
||||
>
|
||||
Their origin or destination yard has no load/unload facility. Mark the yard as
|
||||
a facility in Configuration → Yards, or the cargo can never be worked there.
|
||||
Their origin or destination yard cannot handle that cargo — no facility, or no
|
||||
equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at
|
||||
any facility. Adjust the yard in Configuration → Yards.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ import {
|
||||
type ScheduleConsist,
|
||||
type TrainComposition,
|
||||
type UpdateTrainDetailsPayload,
|
||||
type UsedTrainNumbers,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||
@@ -1825,6 +1826,14 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
|
||||
),
|
||||
|
||||
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
|
||||
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
|
||||
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(
|
||||
"train-builder",
|
||||
"usedTrainNumbers",
|
||||
() => trainBuilderService.usedTrainNumbers().then((r) => r.data),
|
||||
),
|
||||
|
||||
build: endpoint<BuildTrainPayload, TrainComposition>(
|
||||
"train-builder",
|
||||
"build",
|
||||
@@ -1902,6 +1911,22 @@ export const api = {
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
deactivate: endpoint<string, TrainComposition>(
|
||||
"train-builder",
|
||||
"deactivate",
|
||||
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
activate: endpoint<string, TrainComposition>(
|
||||
"train-builder",
|
||||
"activate",
|
||||
(id) => trainBuilderService.activate(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
disband: endpoint<string, void>(
|
||||
"train-builder",
|
||||
"disband",
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface ContractListFilter {
|
||||
tradeDirection?: string;
|
||||
contractKind?: string;
|
||||
paymentCurrency?: string;
|
||||
/** Created-at range (ISO strings, inclusive). */
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
/** Server-side free-text search (contract reference, company name). */
|
||||
search?: string;
|
||||
page?: number;
|
||||
@@ -139,6 +142,8 @@ function buildListParams(filter?: ContractListFilter) {
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.contractKind) params.contractKind = filter.contractKind;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
@@ -164,8 +169,11 @@ export const contractsService = {
|
||||
};
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Freight.IContract> => {
|
||||
const response = await client.get<Freight.IContract>(C.BY_ID(id));
|
||||
getById: async (
|
||||
id: string,
|
||||
opts?: { suppressErrorModal?: boolean },
|
||||
): Promise<Freight.IContract> => {
|
||||
const response = await client.get<Freight.IContract>(C.BY_ID(id), opts);
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
@@ -258,8 +266,11 @@ export const contractsService = {
|
||||
};
|
||||
},
|
||||
|
||||
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
|
||||
const response = await client.get(C.CLEARANCE(id));
|
||||
getClearance: async (
|
||||
id: string,
|
||||
opts?: { suppressErrorModal?: boolean },
|
||||
): Promise<Freight.ContractClearanceView> => {
|
||||
const response = await client.get(C.CLEARANCE(id), opts);
|
||||
return unwrap(response.data) as Freight.ContractClearanceView;
|
||||
},
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ export type BuiltTrainStatus =
|
||||
| "SCHEDULED"
|
||||
| "IN_SERVICE"
|
||||
| "UNDER_MAINTENANCE"
|
||||
| "OUT_OF_SERVICE";
|
||||
| "OUT_OF_SERVICE"
|
||||
| "DEACTIVATED";
|
||||
|
||||
export interface YardRefLite {
|
||||
id: string;
|
||||
@@ -140,6 +141,12 @@ export interface BuildTrainPayload {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Run numbers already claimed by existing (non-deleted) trains. */
|
||||
export interface UsedTrainNumbers {
|
||||
importTrainNumbers: string[];
|
||||
exportTrainNumbers: string[];
|
||||
}
|
||||
|
||||
/** Edit a built train's display identity; omitted fields keep their value. */
|
||||
export interface UpdateTrainDetailsPayload {
|
||||
/** Empty string clears the name. */
|
||||
@@ -261,6 +268,8 @@ export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
|
||||
/** Import/export run numbers already claimed by existing trains. */
|
||||
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
|
||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||
@@ -279,6 +288,11 @@ export const trainBuilderService = {
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
|
||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||
/** Park the train indefinitely — only allowed with no active schedule. */
|
||||
deactivate: (id: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/deactivate`),
|
||||
/** Bring a DEACTIVATED train back to AVAILABLE. */
|
||||
activate: (id: string) => apiClient.post<TrainComposition>(`${BASE}/${id}/activate`),
|
||||
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
|
||||
/** Built trains schedulable on a route (train-scheduling picker). */
|
||||
availableTrains: (routeId: string) =>
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface BookingContainerUnit {
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
/** This container ships back empty after unloading (equipment return). */
|
||||
isReturn?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -231,6 +233,8 @@ export interface BookingListRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractReference?: string | null;
|
||||
/** Needed to link the reference to the contract's detail page. */
|
||||
contractId?: string | null;
|
||||
customerLabel: string;
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
status: BookingStatus;
|
||||
|
||||
@@ -134,7 +134,11 @@ export interface TrainSchedulePreviewResponse {
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
summary: {
|
||||
totalBookings: number;
|
||||
/** Cargo VGM only — display gross instead. */
|
||||
totalWeightTons: number;
|
||||
/** GROSS: cargo + the tare of every wagon in the plan. */
|
||||
grossWeightTons: number;
|
||||
totalTareTons: number;
|
||||
wagonType: string;
|
||||
wagonsNeeded: number;
|
||||
totalLengthMeters: number;
|
||||
@@ -396,23 +400,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
consolidationPartnerRef: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
export interface BatchBoardCounts {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
}
|
||||
|
||||
/** A booking bucket on the detail board (in-window vs pending-contract). */
|
||||
export interface BatchBoardBucket {
|
||||
counts: BatchBoardCounts;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
@@ -439,8 +438,9 @@ export interface BatchBoardScheduleDetail {
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
/** Bookings inside the schedule's booking window (fully-executed contracts). */
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
pendingContract: BatchBoardBucket;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
@@ -845,6 +845,8 @@ export interface CompositionUnassignedBooking {
|
||||
freightType: FreightType | null;
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
|
||||
grossWeightTons: number;
|
||||
status: string | null;
|
||||
schedulingStatus: SchedulingStatus | null;
|
||||
wagonsRequired: number;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
bookingIsSignable,
|
||||
} from "@/pages/bookings/contract/ContractSignButton";
|
||||
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
|
||||
import { ContractReferenceLink } from "@/pages/bookings/booking-display";
|
||||
|
||||
interface BookingRowProps {
|
||||
booking: any;
|
||||
@@ -27,9 +28,15 @@ export const BookingRow = memo(function BookingRow({
|
||||
const AIcon = cfg.action.icon;
|
||||
const ap = ACTION_PROPS[cfg.action.kind];
|
||||
// Payable bookings get an inline "Pay now" that opens the payment modal
|
||||
// instead of navigating to the detail page.
|
||||
// instead of navigating to the detail page. A general contract is payable as
|
||||
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
|
||||
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
|
||||
const payableStatus =
|
||||
booking.bookingType === "GENERAL_CONTRACT"
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
const canPay =
|
||||
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||
booking.status === payableStatus && booking.paymentStatus !== "PAID";
|
||||
// Clearance/operation steps + changes-requested resubmit can be done in place
|
||||
// via a modal on the row.
|
||||
const hasInlineAction = bookingHasInlineAction(booking);
|
||||
@@ -73,6 +80,7 @@ export const BookingRow = memo(function BookingRow({
|
||||
<Text fz={15} fw={700} c="edr-text" truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<ContractReferenceLink booking={booking} />
|
||||
<Text fz={12} c="edr-muted" truncate>
|
||||
{commodity} · {origin} → {dest}
|
||||
</Text>
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
@@ -65,8 +64,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<BookingClearanceWorkflowBanner booking={booking} />
|
||||
<Group justify="space-between" align="center" mb="md" mt="md">
|
||||
{/* The "Clearance progress" stepper moved into the unified journey
|
||||
wizard at the top of the page — this card keeps only the actions. */}
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Clearance documents</CardTitle>
|
||||
{action && (
|
||||
<Button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user