From a5973de6e152adeea935758dca9ed84fda78fb86 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 11:57:27 +0000 Subject: [PATCH 1/7] feat(intercity): a facility only handles the cargo its equipment can lift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containers need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them. Bulk needs far less and is handled at all five facilities. Having a facility was previously enough to load anything, so a container booking through Sebeta or Adama would have been accepted and then had nothing to lift it. - yard_facilities gains handles_container / handles_bulk, both defaulting true so a facility handles everything unless told otherwise; the seeder states the real capability. - The intercity gate now refuses cargo a facility cannot lift, saying which type, not just "no facility". canHandleFreight keeps that rule in the resolver so callers cannot get it subtly wrong. - The intercity list resolves each end against the booking's own freight type, so the view flags a container booking routed through a bulk-only yard while the train is still coming rather than when the load is refused. Import/export untouched — the gate is still DOMESTIC-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2320000000000-YardFacilityFreightTypes.ts | 30 +++++ .../entities/yard-facility.entity.ts | 11 ++ .../services/yard-facilities.service.ts | 119 ++++++++++-------- .../booking-journey.service.ts | 13 +- .../train-scheduling/intercity.service.ts | 16 ++- .../src/seed/yard-facilities.seeder.ts | 33 +++-- .../src/pages/warehouses/IntercityPage.tsx | 32 +++-- 7 files changed, 176 insertions(+), 78 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts 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/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/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/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/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index af0f410a6..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 ( 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/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx index 6a45be0f2..bce1f52e3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) => const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT"; const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED"; -/** Yards with no equipment can never load/unload — surface it before the train arrives. */ -function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) { +/** + * A yard that can't handle THIS booking's cargo can never work it — surface that + * while the train is still coming, not when the load is refused. Containers need + * a facility with a stacker (Indode, Modjo, Dire Dawa); bulk is handled at all of + * them. + */ +function FacilityCell({ + yard, + has, + freightType, +}: { + yard: string | null; + has: boolean | null; + freightType: string | null; +}) { if (!yard) return ; if (has) return {yard}; return ( @@ -95,7 +108,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { {r.customer ?? "—"} - + {atOrigin(r) && isWaiting(r) && ( train here @@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { - + {atDestination(r) && isRiding(r) && ( train here @@ -215,7 +228,7 @@ export default function IntercityPage() { } label="Completed" value={done.length} /> } - label="No facility" + label="Cannot handle" value={blocked.length} color={blocked.length > 0 ? "red" : undefined} /> @@ -229,8 +242,9 @@ export default function IntercityPage() { title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`} mb="md" > - Their origin or destination yard has no load/unload facility. Mark the yard as - a facility in Configuration → Yards, or the cargo can never be worked there. + Their origin or destination yard cannot handle that cargo — no facility, or no + equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at + any facility. Adjust the yard in Configuration → Yards. )} From ee6ad7da8bdb0fedc78c47ae8f4c95253d637cea Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 12:07:27 +0000 Subject: [PATCH 2/7] Auto arrival train --- .../src/seed/indode-facility.seeder.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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..4182c4661 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', From eb998bfdddcbba9f3cde842b9c5193ca6ae72888 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 12:20:12 +0000 Subject: [PATCH 3/7] Facility --- apps/edr-freight-api/src/app.module.ts | 3 +++ .../src/seed/indode-facility.seeder.ts | 20 ++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 5a9ae9ef9..a347b163b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -275,6 +275,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/seed/indode-facility.seeder.ts b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts index 4182c4661..763bc6c54 100644 --- a/apps/edr-freight-api/src/seed/indode-facility.seeder.ts +++ b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts @@ -74,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 { From c0fdffa7efd11a9a86318aafe5fcb7036001d08a Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 14:20:34 +0000 Subject: [PATCH 4/7] Marshalling document empty wagon rendering --- .../train-scheduling.service.spec.ts | 116 ++++++++++++++++++ .../train-scheduling.service.ts | 109 +++++++++++----- 2 files changed, 191 insertions(+), 34 deletions(-) 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 cccf9e57e..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 @@ -2686,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), }; } @@ -2748,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; @@ -2760,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)} @@ -2773,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, @@ -2804,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; } @@ -2831,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)}
@@ -2855,7 +2882,7 @@ export class TrainSchedulingService { - ${rows || 'No wagon allocations found for this export train.'} + ${rows || 'No wagons on this train set.'} @@ -2898,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 ` @@ -2942,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; } @@ -2968,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))}
@@ -2996,7 +3037,7 @@ export class TrainSchedulingService { - ${allocationRows || 'No wagon allocations found for this train.'} + ${allocationRows || 'No wagons on this train set.'} From 21df861979ea1270e8ab07b887df390219f79419 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 14:51:34 +0000 Subject: [PATCH 5/7] back date validator --- .../is-not-backdated.validator.spec.ts | 72 +++++++++++++++++++ .../validators/is-not-backdated.validator.ts | 56 +++++++++++++++ .../dto/record-checkpoint.dto.ts | 13 +++- 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts create mode 100644 apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts 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/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 }) From 536d6043c247ab9f59224bd23e9577ee054fb7aa Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 20 Jul 2026 07:04:18 +0000 Subject: [PATCH 6/7] fix(clearance): allow customs risk to be corrected and keep the trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RiskStep returned early to a badge as soon as a risk level existed, so the control was unreachable and a mis-assigned level could never be corrected. Both the server and the sibling AssignRiskCard treat risk as correctable until duty is advised off it — completeWithMetadata has no already-completed guard and overwrites metadata.riskLevel. RiskStep was stricter than either. It now keeps the control mounted alongside the assigned badge, offers "Reassign risk", and locks to badge-only once DUTY_TAXES_ADVISED completes. The control also reads the persisted level (it was hardcoded to GREEN, so unhiding it alone would have misreported the assignment), and the T1 gate is skipped once a level exists, since risk cannot be assigned without a closed T1 and stale T1 data must not hide the badge. Correcting a level previously left no record of the old value, who changed it, or when — thin ground for a customer-visible level that may be disputed. assignRisk now appends each decision to metadata.riskHistory: the level, the level it replaced, the timestamp, the user id, and a display name resolved at assignment time so the trail shows a person rather than a UUID. riskLevel still carries the current value and always equals the last entry, so existing consumers are unchanged. History lives on the existing metadata JSONB column, so no migration is needed, and the logic sits in assignRisk rather than the shared completeWithMetadata that adviseDuty and others also use. Re-picking the level already in force is not recorded — it changed nothing. Co-Authored-By: Claude Opus 4.8 --- .../contracts/booking-clearance.service.ts | 13 +++- .../clearance-milestone.risk.spec.ts | 76 +++++++++++++++++++ .../contracts/clearance-milestone.service.ts | 37 ++++++++- .../contracts/contract-clearance.service.ts | 12 ++- .../modules/contracts/contracts.controller.ts | 6 +- .../entities/clearance-milestone.entity.ts | 23 +++++- .../contracts/PhasedClearanceActionPanel.tsx | 67 +++++++++++++--- packages/types/src/freight/contracts.ts | 20 +++++ packages/types/src/freight/index.ts | 2 + 9 files changed, 241 insertions(+), 15 deletions(-) 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-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/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-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/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 362d3c6ef..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; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 8b50fecba..1dabbca19 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -780,6 +780,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; From 2906c6bb45ae113ade59dea90a53986039ad45de Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 20 Jul 2026 07:06:19 +0000 Subject: [PATCH 7/7] test(train-scheduling): cover the checkpoint backdating guard on the DTO The IsNotBackdated validator was covered in isolation, but not on the DTO that actually carries it. Asserts a backdated occurredAt is rejected, that "now" passes, and that omitting the field still validates so the service can stamp it. Co-Authored-By: Claude Opus 4.8 --- .../dto/record-checkpoint.dto.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.spec.ts 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); + }); +});