diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index e435267a1..3ca5f6cb1 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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(); diff --git a/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts new file mode 100644 index 000000000..dd22f3c37 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts @@ -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>) => + 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'); + }); +}); diff --git a/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts new file mode 100644 index 000000000..7a767e7f8 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts @@ -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, + }); + }; +} diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts new file mode 100644 index 000000000..a7b007617 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts new file mode 100644 index 000000000..f4628db36 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2300000000000-RepairGpsTrackingTables.ts @@ -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 { + 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 { + // No-op: dropping the tables would discard tracker history on environments + // where this migration was the one that created them. AddGpsTracking owns + // the teardown. + } +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts new file mode 100644 index 000000000..d6e7ae273 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + DROP COLUMN IF EXISTS handles_container, + DROP COLUMN IF EXISTS handles_bulk + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts new file mode 100644 index 000000000..566d0308e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2360000000000-RefreshContractPricingArticles.ts @@ -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 { + 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 { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts new file mode 100644 index 000000000..1971f6166 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2370000000000-AddContainerUnitReturnFlag.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts b/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts new file mode 100644 index 000000000..2c252f127 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts @@ -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 { + await queryRunner.query( + `ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`, + ); + } + + public async down(): Promise { + // Enum values cannot be removed in Postgres; leaving the label is harmless. + } +} diff --git a/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts new file mode 100644 index 000000000..99c764806 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts @@ -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 { + 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 { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts b/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts new file mode 100644 index 000000000..31263f4b2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Yard soft-delete now appends `@` 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 { + await queryRunner.query( + `ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`, + ); + } + + public async down(): Promise { + // Narrowing would fail on suffixed codes; keep 40. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index d3fe02d5f..cbb630794 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index a978fd22b..507ef43d8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -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({ diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 690b66385..aacc68012 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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, { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index aeae7d10a..c93ebd9c7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1287,6 +1287,23 @@ export class BookingsRepository extends BaseRepository { .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 { + 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 { return this.repository @@ -1342,6 +1359,10 @@ export class BookingsRepository extends BaseRepository { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index 619013280..217ffe5f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 59dad2248..810232993 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts index eb77533ac..4ad6dab75 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts @@ -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); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index ed3597d57..b6d57263a 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -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 { 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. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 826bc33b0..fd1f530b8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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; + 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; 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 { 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, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d3bbaf098..d752d071a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index b62b89e32..cb91fbee6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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), ); } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 256b11b63..3b5a30ca0 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index d4676b8cd..14e3b86dd 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index aa1c956e0..f155a27be 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -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). */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 5507692d9..41971bbf1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts new file mode 100644 index 000000000..480b2c484 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -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', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 09f35c458..a5b5bfc30 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts index ba5a0d671..5712eed03 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts index 808a102e5..226b1114d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -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 `@` 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 }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 03be1b3d0..d1715d9fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index edd9ac9f0..488865f38 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts index f32b0129a..4b69c328f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -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 { - 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 { + 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 { - 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[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; } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 69eab608a..b698b456c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -31,7 +31,7 @@ export class YardsService { /** Create a yard. */ async create(dto: CreateYardDto): Promise { - 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 `@` + * 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 { - 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); } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 8e40c0384..08b906e84 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -21,6 +21,10 @@ export class TrainSchedulesRepository extends BaseRepository { findByIdWithFullGraph(id: string, manager?: EntityManager): Promise { 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 diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index c19140d8a..fc97f772b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 866885074..4a836060e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 { 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> { + 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 { + 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 { 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>(); + 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(); + 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(); + 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 { @@ -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 >; 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 { + 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(); + 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 { 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 { + // 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 { + 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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 6f366d060..fb140158c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -352,10 +352,19 @@ export class BookingJourneyService { ): Promise { 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.`, ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 3d505e113..e17445b4d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -150,10 +150,18 @@ export class BookingNotifierService { ): Promise { 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); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.spec.ts new file mode 100644 index 000000000..58cf5da12 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.spec.ts @@ -0,0 +1,37 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { RecordCheckpointDto } from './record-checkpoint.dto'; + +const validateBody = (body: Record) => + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index 7f778760d..da7ebc0e7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -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 }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index f54dc138e..b35650e16 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -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 | 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 { + if (!schedule.routeId) return []; + return this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + } + + private milestoneSequenceOf( + schedule: TrainSchedule, + milestones: RouteMilestone[], + ): Map | 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 | 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, @@ -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, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts new file mode 100644 index 000000000..55cacaf03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts new file mode 100644 index 000000000..85b6d0878 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts @@ -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 { + 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(); + + private async placeRemainderInner( + splitBooking: Booking, + ): Promise { + 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 + >; + 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 { + 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 { + 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 = []; + 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[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 { + // 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, + })); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 6bc36c4fc..3e142f778 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index fa8623b4a..35503d432 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index c0ff19b25..af1a9eb45 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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(/\s* { + 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'); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index eb3fd7201..9d426077e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 { + 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 = `${esc(wagon.sequenceNo)} + ${esc(wagon.physicalWagon?.wagonNumber)} + ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} + ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} + ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} + ${esc(Number(wagon.capacityTons || 0).toFixed(3))}`; + 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 [ + ` + ${wagonCells} + EMPTY — no cargo allocated + `, + ]; + } + return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const company = booking?.company as Record | 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 ` - ${esc(wagon.sequenceNo)} - ${esc(wagon.physicalWagon?.wagonNumber)} - ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} - ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} - ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} - ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${wagonCells} ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} @@ -2677,10 +2797,11 @@ export class TrainSchedulingService { ${esc(chassisNumbers)} ${esc(sealNumbers)} `; - }), - ) + }); + }) .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 {
Total loaded weight${esc(totalWeight.toFixed(3))} T
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
-
Wagons${esc(schedule.trainSet?.wagons?.length ?? 0)}
+
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Bookings${esc(schedule.scheduleBookings?.length ?? 0)}
Status${esc(schedule.status)}
Direction${esc(schedule.direction)}
@@ -2759,7 +2882,7 @@ export class TrainSchedulingService { - ${rows || 'No wagon allocations found for this export train.'} + ${rows || 'No wagons on this train set.'} @@ -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 = `${esc(wagon.sequenceNo)} + ${esc(wagon.wagonNumber)}`; + // An empty wagon still runs in the consist, so it still gets a line — see + // buildExportLoadListHtml. + if (wagon.allocations.length === 0) { + return [ + ` + ${wagonCells} + EMPTY — no cargo allocated + `, + ]; + } + return wagon.allocations.map( (allocation) => ` - ${esc(wagon.sequenceNo)} - ${esc(wagon.wagonNumber)} + ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `, - ), - ) + ); + }) .join(''); return ` @@ -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 {
Origin${esc(loadList.origin)}
Destination${esc(loadList.destination)}
Total bookings${esc(loadList.totalBookings)}
-
Wagons${esc(loadList.wagons.length)}
+
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -2900,7 +3037,7 @@ export class TrainSchedulingService { - ${allocationRows || 'No wagon allocations found for this train.'} + ${allocationRows || 'No wagons on this train set.'} @@ -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, builtTrainId: string | null = null, pinnedToScheduleIds: Set = 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>; + expiresAt: number; + } | null = null; + private async loadWagonTareDims(): Promise<{ byWagonTypeId: Map; 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(); - 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(); + 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 { - 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> { - 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> { + const schedule = + preloadedSchedule ?? + (await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)); const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id); if (!wagonIds.length) return new Set(); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 9b7454f36..babf11ef1 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -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) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 10b400993..3658f4f37 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -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(); + const exportTrainNumbers = new Set(); + 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 { await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts b/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts new file mode 100644 index 000000000..85bb5e88a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts @@ -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 = { + '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 = 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; +}; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 1549caf0b..1deedd14d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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; diff --git a/apps/edr-freight-api/src/scripts/update-wagon-runs.ts b/apps/edr-freight-api/src/scripts/update-wagon-runs.ts new file mode 100644 index 000000000..d24f6245e --- /dev/null +++ b/apps/edr-freight-api/src/scripts/update-wagon-runs.ts @@ -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 [--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 [--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(); + 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(); + 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); +}); diff --git a/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts b/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts index 4df5d9028..f717e73d6 100644 --- a/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts @@ -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() diff --git a/apps/edr-freight-api/src/seed/indode-facility.seeder.ts b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts index 4b4ae23f0..763bc6c54 100644 --- a/apps/edr-freight-api/src/seed/indode-facility.seeder.ts +++ b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts @@ -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 { diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts index 47754bd93..1822a877f 100644 --- a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -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 { - 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( diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index c82f64ed2..813595690 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -846,6 +846,16 @@ const App = () => { } /> + + + + } + /> {/* GL (Path B) contract clearance review hub */} ; 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); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx new file mode 100644 index 000000000..94061eee2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ContractReferenceLink.tsx @@ -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 ( + e.stopPropagation()} + className={ + className ?? + "block truncate font-mono text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground" + } + > + {contractReference} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index aa14fe8cd..3ee5d72a0 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -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 - - {booking.reference} - + + + {booking.reference} + + + {booking.schedulingStatus ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 5633745aa..97199afc3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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: , + color: "#C0392B", + }, + contract?.isReefer && { + key: "isReefer", + label: "Refrigerated", + icon: , + color: "#2E5B96", + }, + contractWithReturn && { + key: "isReturn", + label: "With return", + icon: , + 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, ) => - 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 && ( - - patchLine(lineIdx, { - hazardousQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} - {contract.isReefer && ( - - patchLine(lineIdx, { - reeferQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} - {contractWithReturn && ( - - patchLine(lineIdx, { - returnQuantity: e.currentTarget.value, - }) - } - radius={10} - styles={fieldStyles} - /> - )} Per-container details + {handlingColumns.length > 0 ? ( + + 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("")} + . + + ) : null} + {/* 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 && ( + + + Container number * + + + Seal number + + + VGM (tons) * + + {handlingColumns.map((col) => ( + + + {col.icon} + + + {col.label} + + + ))} + + )} {line.units.map((unit, unitIdx) => ( - + @@ -1274,11 +1349,11 @@ export default function GlCreateBookingForm() { } radius={10} styles={fieldStyles} + style={{ flex: 1 }} /> + {handlingColumns.map((col) => ( + + + patchUnit(lineIdx, unitIdx, { + [col.key]: e.currentTarget.checked, + }) + } + size="sm" + /> + + ))} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 699706654..9d5dfe65e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -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("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(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 ? ( + - ); + {priorDecisions.length > 0 ? ( + + + Previously + + {priorDecisions.map((entry, index) => ( + + {entry.level} + {" · "} + {new Date(entry.assignedAt).toLocaleString()} + {entry.assignedBy ? ` · ${entry.assignedBy}` : ""} + {entry.note ? ` · ${entry.note}` : ""} + + ))} + + ) : null} + + ) : 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 ( + {assignedSummary} - 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."} diff --git a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx index fb780261a..07c9a39d1 100644 --- a/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/errors/ApiErrorModal.tsx @@ -104,6 +104,11 @@ export function ApiErrorModal() { onClose={close} centered radius="md" + // Mounted at the app root, so its portal is FIRST in — 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={ diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 2cedad1eb..05b4bd546 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -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 { 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" /> - 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" /> setExportNo(e.currentTarget.value)} - maxLength={20} + readOnly + variant="filled" radius="md" /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts b/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts index 7cbdfcc52..ef5a661e8 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts @@ -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"; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx index 25bf2480f..d84d4e8be 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWarningsAlert.tsx @@ -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 ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 867a0f895..c7fc49b18 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index 2805ba60a..af8917e00 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -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 }) { /> - {wagon.assignedWeightTons}/{wagon.capacityTons}T + {grossTons}/{maxGrossTons}T ) : ( @@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { {!wagon.isEmpty ? ( - {wagon.assignedWeightTons}T + {grossTons}T ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx index 086f78ddc..e5b63ea24 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/WagonPlanGrid.tsx @@ -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({ {isBulk ? ( - Load: {totalAssigned} / {totalCapacity}T + Gross: {totalAssigned} / {totalCapacity}T ) : null} @@ -90,8 +101,9 @@ export function WagonPlanGrid({ {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({ {label === "BULK" ? ( - {alloc.allocatedWeightTons}T + {alloc.allocatedWeightTons}T cargo ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx index 00df4700c..9e7062ad6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx @@ -132,7 +132,7 @@ export const BookingDetailModal = ({ /> } - label="Weight" + label="Gross weight" value={ {booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 8af9a6e34..8ac374c1a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -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)`; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx index 4d2b1369c..80cbeaad6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx @@ -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 ( @@ -40,7 +42,7 @@ export const RemoveBookingModal = ({ - Weight: {allocation.allocatedWeightTons?.toFixed(2) || 0} T + Gross weight: {grossTons.toFixed(2)} T Wagon Slot: #{wagon.sequenceNo} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 6ece98ef2..e5b12fe1c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -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 ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx index 8ec1c7f4e..3403258ef 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx @@ -98,7 +98,7 @@ export const TrainStatsBar = ({ } - label="Weight" + label="Gross weight" pct={weightPct} current={weightUsed.toFixed(1)} max={weightMax?.toFixed(1) ?? "∞"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx index e5225f8dd..3af00fd88 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index f735e6474..915cfa629 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts b/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts index b7af36639..1ae998c23 100644 --- a/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts +++ b/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts @@ -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) : ""; +}; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index bc10fd064..a0594094c 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -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") diff --git a/apps/edr-freight-web/backoffice/src/hooks/useImportTrainNumberOptions.ts b/apps/edr-freight-web/backoffice/src/hooks/useImportTrainNumberOptions.ts new file mode 100644 index 000000000..a26e127f3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useImportTrainNumberOptions.ts @@ -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(() => { + 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, + }; +} diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 5b2bcb255..0a270372e 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -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. diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 72c16215b..3eb775e95 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -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 (
{ref ? ( - {ref} + // 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 ? ( + + ) : ( + {ref} + )) ) : ( )} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 79d35aa7d..7be7871dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -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 + ) : canRebookExpired ? ( + ) : undefined } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 5b365bc9d..945d142df 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 6c8565d03..63dbb5d0b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -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() { @@ -176,12 +185,9 @@ export default function ContractClearanceDetailPage() { + + {row.status === "EXPIRED" + ? "Contract expired" + : row.status === "CANCELLED" + ? "Cancelled" + : "Rejected"} + + + ); + } if (row.paymentExpired) { return ( + // 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"; }; diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index ea4e78691..1752aa7c0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -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("all"); + // Filter controls (empty/null = "all"). + const [statusFilter, setStatusFilter] = useState([]); + const [directionFilter, setDirectionFilter] = useState(null); + const [freightTypeFilter, setFreightTypeFilter] = useState( + null, + ); + const [kindFilter, setKindFilter] = useState(null); + const [currencyFilter, setCurrencyFilter] = useState(null); + const [createdFrom, setCreatedFrom] = useState(null); + const [createdTo, setCreatedTo] = useState(null); + const [sort, setSort] = useState("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() { - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - resetPage(); - }} - rightSection={ - query && ( - { - setQuery(""); - resetPage(); - }} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + } + value={query} + onChange={(e) => { + setQuery(e.target.value); + resetPage(); + }} + rightSection={ + query && ( + { + setQuery(""); + resetPage(); + }} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + { + setDirectionFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 150 }} + aria-label="Filter by trade direction" + /> + { + setKindFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 160 }} + aria-label="Filter by contract kind" + /> + +

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

+ +
+ + + + +
+
+
+
+
+ +
+
+ +
+ + + + + +
+
+ + + + + + + +
+ +
+ + + + + + + + +
JourneyDuplicate Bookings
+
+
+ + + + + diff --git a/booking-extractor.html b/booking-extractor.html new file mode 100644 index 000000000..844c98b2c --- /dev/null +++ b/booking-extractor.html @@ -0,0 +1,256 @@ + + + + + + EDR Booking Extractor + + + + +

EDR Booking Extractor

+ +
+ +
+ + Drop bookings.json here or click to browse +
+

Accepts a JSON array of bookings or an object with a bookings key.

+
+ + + +
+
+ +
+
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
+
+
+ + + + + diff --git a/booking-proxy.mjs b/booking-proxy.mjs new file mode 100644 index 000000000..27f9248ee --- /dev/null +++ b/booking-proxy.mjs @@ -0,0 +1,53 @@ +import http from 'http'; +import https from 'https'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const PORT = 8080; +const __dir = path.dirname(fileURLToPath(import.meta.url)); + +const server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + // Serve any .html file in the same directory + if (req.url === '/' || req.url.endsWith('.html')) { + const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); + const filepath = path.join(__dir, filename); + if (fs.existsSync(filepath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + fs.createReadStream(filepath).pipe(res); + } else { + res.writeHead(404); res.end('Not found'); + } + return; + } + + // Proxy /proxy?url= + if (req.url.startsWith('/proxy?url=')) { + const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); + const parsed = new URL(target); + const mod = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: req.method, + headers: { ...req.headers, host: parsed.hostname }, + }; + const proxy = mod.request(options, (apiRes) => { + res.writeHead(apiRes.statusCode, apiRes.headers); + apiRes.pipe(res); + }); + proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); + req.pipe(proxy); + return; + } + + res.writeHead(404); res.end(); +}); + +server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts b/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts index 39a0f60f8..d9174627b 100644 --- a/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts +++ b/packages/payment-providers/src/webhooks/telebirr-webhook.types.ts @@ -2,6 +2,9 @@ export interface TelebirrWebhookPayload { merch_order_id: string; payment_order_id: string; trade_status: string; + /** Telebirr's webhook (notify) sends the transaction id as camelCase `transId`; + * the queryOrder/status-query biz_content uses snake_case `trans_id`. Support both. */ + transId?: string; trans_id?: string; total_amount?: string; trans_currency?: string; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 9aa36374a..299c25dc9 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -407,6 +407,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; @@ -440,9 +442,27 @@ export type MilestoneStatus = "PENDING" | "COMPLETED" | "SKIPPED"; export const CUSTOMS_RISK_LEVELS = ["GREEN", "YELLOW", "RED"] as const; export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number]; +/** + * One customs risk decision. The level stays correctable until duty is advised + * off it and is customer-visible, so every assignment is kept rather than + * overwritten. + */ +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 carried by RISK_ASSIGNED / DUTY_TAXES_ADVISED milestones. */ export interface MilestoneMetadata { riskLevel?: CustomsRiskLevel; + /** Every risk decision, oldest first; the last entry matches `riskLevel`. */ + riskHistory?: RiskAssignmentRecord[]; dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; @@ -729,14 +749,22 @@ export interface CreateContainerUnitDto { containerNumber: string; sealNumber?: string; vgmTons: number; + /** Per-container handling opt-ins, entered alongside this container's VGM. */ isHazardous?: boolean; isReefer?: boolean; + /** This container ships back empty (equipment return). */ + isReturn?: boolean; } export interface CreateBookingContainerLineDto { /** "20ft" | "40ft" — must be in the contract's cargo scope. */ containerSize: string; quantity: number; + /** + * Line totals, derived from the per-unit switches above. The API recomputes + * them from `units` whenever any unit carries a flag, so they are only + * authoritative for callers that don't send per-unit flags. + */ hazardousQuantity?: number; reeferQuantity?: number; /** diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 12f3d548f..be13ed349 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -137,6 +137,8 @@ export enum TrainStatus { InService = "IN_SERVICE", UnderMaintenance = "UNDER_MAINTENANCE", OutOfService = "OUT_OF_SERVICE", + /** Parked indefinitely by staff; only allowed with no active schedule. */ + Deactivated = "DEACTIVATED", } export enum CargoType { @@ -779,6 +781,8 @@ export interface ClearanceView { /** 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?: import("./contracts").RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: import("./contracts").ClearanceSecondDuty | null; importReleaseGranted?: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 108b45d8b..6ace365ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,6 +953,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -22123,6 +22126,14 @@ snapshots: date-fns: 3.6.0 react: 19.2.6 + react-day-picker@9.14.0(react@18.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.4.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-day-picker@9.14.0(react@19.2.6): dependencies: '@date-fns/tz': 1.5.0 diff --git a/ticket-extractor.html b/ticket-extractor.html new file mode 100644 index 000000000..17be60576 --- /dev/null +++ b/ticket-extractor.html @@ -0,0 +1,239 @@ + + + + + + EDR Ticket Extractor + + + + +

EDR Ticket Extractor

+ +
+ +
+ + Drop tickets.json here or click to browse +
+

Accepts a JSON array of tickets or an object with a tickets key.

+
+ + + +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
+
+
+ + + + +