From ddddcfb71ff6e70d7e86964d4febea2b0c1c3855 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 03:36:35 +0000 Subject: [PATCH 01/31] Refactor wagon type handling and container wagon calculations - Removed maxWagonsPerTrain from WagonType entity and related DTOs. - Updated containerWagonsForLines function to calculate required wagons based on container lines more accurately. - Added unit tests for containerWagonsForLines to ensure correct calculations. - Adjusted related services and scripts to reflect the removal of maxWagonsPerTrain. - Enhanced booking and contract components to use new status labels for better user experience. - Implemented validation for unique container numbers in shipment forms. --- .../docs/priority-batch-window-flow.md | 74 +++++++++++ ...000000000-AddLocomotiveOverageTolerance.ts | 27 ++++ ...00000000-DropWagonTypeMaxWagonsPerTrain.ts | 26 ++++ .../2060000000000-SeedRailWagonTypes.ts | 46 +++++++ .../modules/bookings/bookings.repository.ts | 5 + .../locomotives/dto/create-locomotive.dto.ts | 17 +++ .../locomotives/entities/locomotive.entity.ts | 20 +++ .../locomotives/locomotives.service.ts | 2 + .../booking-batch.service.spec.ts | 73 ++++++++++- .../train-scheduling/booking-batch.service.ts | 122 +++++++++++++++--- .../train-scheduling/fleet-plan.util.spec.ts | 1 - .../train-scheduling/fleet-plan.util.ts | 10 +- .../train-capacity.util.spec.ts | 28 ++++ .../train-scheduling/train-capacity.util.ts | 37 ++++-- .../train-scheduling.service.spec.ts | 2 - .../train-scheduling.service.ts | 42 ++++-- .../train-scheduling/wagon-plan.util.spec.ts | 60 ++++++++- .../train-scheduling/wagon-plan.util.ts | 45 +++++-- .../wagon-types/dto/create-wagon-type.dto.ts | 11 -- .../wagon-types/entities/wagon-type.entity.ts | 3 - .../wagon-types/wagon-types.service.ts | 3 - .../scripts/seed-gate-pass-train-scenarios.ts | 1 - .../seed-negad-indode-arrived-train.ts | 1 - .../src/seed/demo-bookings.seeder.ts | 4 - .../src/pages/fleet/FleetCrudPages.tsx | 9 -- .../src/pages/ruleEngine/config/resources.ts | 7 - .../src/services/wagon-types.service.ts | 1 - .../src/pages/MyPortalPage/constants.ts | 13 ++ .../components/BookingPaymentPanel.tsx | 3 +- .../components/KeyFactsStrip.tsx | 9 +- .../components/PageHeader.tsx | 4 +- .../components/ScheduleCard.tsx | 7 +- .../src/pages/bookings/booking-display.tsx | 37 +++++- .../ContractBookingWindowsSection.tsx | 26 +++- .../ContractClearanceWorkflowBanner.tsx | 3 +- .../pages/contracts/ContractDetailPage.tsx | 43 +++++- .../src/pages/contracts/NewShipmentPage.tsx | 3 + .../src/pages/contracts/contract-ui.tsx | 16 ++- .../contracts/new-shipment-form/schema.ts | 33 ++++- 39 files changed, 753 insertions(+), 121 deletions(-) create mode 100644 apps/edr-freight-api/docs/priority-batch-window-flow.md create mode 100644 apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts create mode 100644 apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts create mode 100644 apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts diff --git a/apps/edr-freight-api/docs/priority-batch-window-flow.md b/apps/edr-freight-api/docs/priority-batch-window-flow.md new file mode 100644 index 000000000..9c8a52878 --- /dev/null +++ b/apps/edr-freight-api/docs/priority-batch-window-flow.md @@ -0,0 +1,74 @@ +# Priority & Batch Window Flow (Import, Freight) + +Export = no batch, no priority. Pure first-come-first-served (`booking-batch.service.ts:462-467, 625-628`). Everything below is import only. + +## Step by step + +**1. Booking submitted → priority score computed** +`booking-transition.service.ts:110-111,196-197` → `booking-pricing.service.ts:403-407` `computeSubmitPriorityScore()` → `rule-engine.service.ts:118`. +- Government booking: `+50,000` (`government-priority.constants.ts:2`, applied `rule-engine.service.ts:201`) +- Plus cargo/weight modifiers +- Stored on `booking.priorityScore` + +**2. Window opens (PRE_WINDOW → OPEN)** +Cron tick every 10s: `booking-window.service.ts:63` → `advanceImport` → `booking-window.service.ts:232-251`. +Times computed by `computeImportWindowTimes` (`batch-window.util.ts:248-283`). + +**3. Customers book during OPEN** +Booking lands as: +- Commercial → `FULLY_EXECUTED` +- Government → `APPROVED/PAID` (skips contract flow) + +**4. Window closes (OPEN → DOC_REVIEW)** +`booking-window.service.ts:254-268`. Staff review docs for `docReviewMinutes`. + +**5. Doc review ends** +Staff `completeDocReview()` (`booking-window.service.ts:124-159`) or timeout → `booking-window.service.ts:270-295`. +Before batch runs: `expireUnacceptedForRouteDay` (`booking-batch.service.ts:1853-1883`) kills never-accepted bookings so they can't compete. + +**6. Batch fill runs** +`processRouteDay` → `fillRouteDay` (`booking-batch.service.ts:1138-1319`), or single-schedule `fillSchedule` (`:1018-1128`). + +- Pool pulled pre-sorted: `findBatchPool`/`findBatchPoolByCorridorDay` (`bookings.repository.ts:991-1008, 1055-1083`) + `ORDER BY is_government DESC, priority_score DESC, fully_executed_at ASC, created_at ASC` +- Consolidated pairs grouped as one atomic unit: `groupConsolidatedPool` (`:1962-1987`) — never split. +- Greedy placement, earliest-departing fitting train first: loop at `:1218-1306`. +- No fit + government booking → `preemptForGovernment` (`:1891-1910`): bumps lowest-`priorityScore` commercial victim first, only if legs overlap (`:1920`). +- No fit + commercial import (GENERAL/ONE_TIME) → maybe partial "split" offer: `maybeOfferPartial`/`isSplitEligible` (`:1326-1370`). +- Still no fit → stays pooled, `notifier.unplaced` (`:1278-1280`). + +**7. Placed bookings get reserved/allocated** +- Commercial: `reserve()` (`:1673-1703`) → `SELECTED_FOR_BATCH`, payment deadline set, DOC_REVIEW→PAYMENT (`booking-window.service.ts:275-294`). +- Government: `allocate()` directly (`:1706-1746`), no payment step. + +**8. Payment phase ends** +`booking-window.service.ts:297-309` → `settleDueReservations` → `settleReserved` (`:1437-1491`): +- paid → allocated +- unpaid → expired, capacity freed + +Then `concludeCycle` (`:315-373`): +- Train full → `DONE` + auto-finalize (`:320-329`) +- Not full → reopen same/next day (`nextCycleOpensAt` / office hours, `:331-372`, `batch-window.util.ts:217-224`) or `DONE` if no cycle fits before departure. + +**9. Backstop** +`settleOverdueReservations` (`booking-window.service.ts:388-406`) catches any reservation whose deadline passed outside the normal tick. + +## Phase enum + +`PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → (reopen PRE_WINDOW | DONE)` +(`booking-window.config.ts:27-34`) + +## What decides priority + +1. `is_government` — always first, both in SQL sort and `compareSchedulingPriority` util (`compare-scheduling-priority.util.ts:9-23`) +2. `priority_score` DESC (rule engine: government bonus + cargo/weight modifiers) +3. `fully_executed_at` ASC (earlier wins) +4. `created_at` ASC + +## Edge cases + +- Government preemption only bumps if legs overlap; picks lowest-priority victim first. +- Consolidated pairs are both-or-neither, never split (`:1326-1334, 1793`). +- Only GENERAL/ONE_TIME import bookings are eligible for partial "split" offers. +- Per-unit try/catch around reserve — one failure can't cause silent trickle/stagger allocation (comment at `:1283-1288`). +- Each train freezes its own rule snapshot at window-open time, not live config (`booking-window.service.ts:85-93`). diff --git a/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts b/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts new file mode 100644 index 000000000..4bda8dde1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds locomotives.overage_tolerance_tons / overage_tolerance_meters: an + * optional per-locomotive deviation allowance above max_pull_weight_tons / + * max_train_length_meters. Nullable, defaults to no tolerance so existing + * strict-cap behavior is unchanged until staff sets a value. + */ +export class AddLocomotiveOverageTolerance2040000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS overage_tolerance_tons NUMERIC(10, 3), + ADD COLUMN IF NOT EXISTS overage_tolerance_meters NUMERIC(10, 3); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS overage_tolerance_tons, + DROP COLUMN IF EXISTS overage_tolerance_meters; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts b/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts new file mode 100644 index 000000000..927504ee6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drops wagon_types.max_wagons_per_train. Train wagon-count caps are already + * derived from locomotive + wagon length/weight (train-capacity.util.ts) and + * the global train_scheduling_global_rules row — this per-wagon-type override + * was unused by that derivation and only added a confusing "Max / train" + * field to the wagon type form. + */ +export class DropWagonTypeMaxWagonsPerTrain2050000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_types + DROP COLUMN IF EXISTS max_wagons_per_train; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_types + ADD COLUMN IF NOT EXISTS max_wagons_per_train INT; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts b/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts new file mode 100644 index 000000000..ed9e8fae2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Upserts the 10 real EDR wagon types (code, name, capacity, length, tare + * weight) by code. Overwrites any existing row with the same code so + * previously-seeded demo values (e.g. NW5/PW2/CW3 from demo-bookings.seeder) + * are replaced with the real spec. + */ +export class SeedRailWagonTypes2060000000000 implements MigrationInterface { + private readonly wagonTypes = [ + { code: 'NW7', name: 'Double deck sedan wagon', capacityTons: 22, lengthMeters: 26.066, tareWeightTons: 37.1 }, + { code: 'NW5', name: 'Flat wagon', capacityTons: 70, lengthMeters: 13.966, tareWeightTons: 22.4 }, + { code: 'PW2', name: 'Box wagon', capacityTons: 70, lengthMeters: 17.066, tareWeightTons: 25.2 }, + { code: 'GW2', name: 'Tank wagon', capacityTons: 70, lengthMeters: 12.228, tareWeightTons: 23 }, + { code: 'CW4', name: 'Gondola covered wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 24.8 }, + { code: 'CW3', name: 'Gondola open wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 23.4 }, + { code: 'KW2', name: 'Hopper covered wagon', capacityTons: 69, lengthMeters: 16.466, tareWeightTons: 25.2 }, + { code: 'KW3', name: 'Hopper wagon open', capacityTons: 70, lengthMeters: 14.4, tareWeightTons: 24 }, + { code: 'NW6', name: 'Flat wagon (long)', capacityTons: 70, lengthMeters: 18.56, tareWeightTons: 25.3 }, + { code: 'BW1', name: 'Refrigerated wagon', capacityTons: 38, lengthMeters: 21.996, tareWeightTons: 32.1 }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + for (const wt of this.wagonTypes) { + await queryRunner.query( + ` + INSERT INTO freight.wagon_types (code, name, capacity_tons, length_meters, tare_weight_tons, is_active) + VALUES ($1, $2, $3, $4, $5, true) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + tare_weight_tons = EXCLUDED.tare_weight_tons; + `, + [wt.code, wt.name, wt.capacityTons, wt.lengthMeters, wt.tareWeightTons], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.wagon_types WHERE code = ANY($1);`, + [this.wagonTypes.map((wt) => wt.code)], + ); + } +} 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 fa11fc66c..2fe45bd8a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -993,6 +993,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') @@ -1023,6 +1024,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id = :originYardId', { originYardId }) .andWhere('booking.destination_yard_id = :destinationYardId', { @@ -1061,6 +1063,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { @@ -1125,6 +1128,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') @@ -1138,6 +1142,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) .getMany(); diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts index 66d0100d0..d46622ba4 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -49,6 +49,23 @@ export class CreateLocomotiveDto { @Min(0) maxTrainLengthMeters!: number; + // Allowed deviation above maxPullWeightTons before scheduling blocks the train + // (e.g. 90 lets a 3,500T-rated locomotive pull up to 3,590T). Omit/0 = strict cap. + @ApiPropertyOptional({ example: 90 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + overageToleranceTons?: number; + + // Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. + @ApiPropertyOptional({ example: 0 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + overageToleranceMeters?: number; + @ApiPropertyOptional({ example: 4200 }) @IsOptional() @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 6dcd9ad3e..d3214b6c4 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -39,6 +39,26 @@ export class Locomotive extends BaseEntity { @Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 }) maxTrainLengthMeters!: number; + /** Allowed deviation above maxPullWeightTons before a train is blocked (e.g. the 37th PW2 wagon in the fertilizer example runs 90T over 3,500T and is still accepted). Null/0 = no tolerance. */ + @Column({ + name: 'overage_tolerance_tons', + type: 'numeric', + precision: 10, + scale: 3, + nullable: true, + }) + overageToleranceTons?: number | null; + + /** Allowed deviation above maxTrainLengthMeters before a train is blocked. Null/0 = no tolerance. */ + @Column({ + name: 'overage_tolerance_meters', + type: 'numeric', + precision: 10, + scale: 3, + nullable: true, + }) + overageToleranceMeters?: number | null; + @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) status!: LocomotiveStatus; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index ae9a41608..65700fd93 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -64,6 +64,8 @@ export class LocomotivesService { maxPullWeightTons: dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS, maxTrainLengthMeters: dto.maxTrainLengthMeters, + overageToleranceTons: dto.overageToleranceTons ?? null, + overageToleranceMeters: dto.overageToleranceMeters ?? null, powerKw: dto.powerKw ?? null, tractionForceKn: dto.tractionForceKn ?? null, maxSpeedKmh: dto.maxSpeedKmh ?? null, 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 31d3c8855..6abd56b9f 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 @@ -163,7 +163,7 @@ describe('BookingBatchService — PAID reconcile', () => { }); it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { - const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined); + const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined); @@ -181,6 +181,77 @@ describe('BookingBatchService — PAID reconcile', () => { expect(reconcileOrder).toBeLessThan(wagonOrder); }); + describe('extendPaymentPhaseForTopUp', () => { + const schedRepo = () => dataSource.getRepository(); + + it('pushes paymentPhaseEndsAt out when a fresh window exceeds it', async () => { + const soon = new Date(Date.now() + 5_000); // phase almost over + const departure = new Date(Date.now() + 24 * 3_600_000); + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: soon, + scheduledDepartureDate: departure, + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + // paymentWindowMinutes = 60 (mock) → new end ≈ now + 1h, which is > soon. + expect(schedRepo().update).toHaveBeenCalledWith( + scheduleId, + expect.objectContaining({ paymentPhaseEndsAt: expect.any(Date) }), + ); + const [, patch] = schedRepo().update.mock.calls.at(-1)!; + expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeGreaterThan( + soon.getTime(), + ); + }); + + it('does not pull the deadline in when the current end is already later', async () => { + const far = new Date(Date.now() + 10 * 3_600_000); // 10h out, beyond a 1h window + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: far, + scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000), + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + expect(schedRepo().update).not.toHaveBeenCalled(); + }); + + it('is a no-op outside the PAYMENT phase', async () => { + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'OPEN', + paymentPhaseEndsAt: null, + scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000), + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + expect(schedRepo().update).not.toHaveBeenCalled(); + }); + + it('never extends past departure', async () => { + const departure = new Date(Date.now() + 60_000); // 1 min away + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: new Date(Date.now() + 1_000), + scheduledDepartureDate: departure, + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + const [, patch] = schedRepo().update.mock.calls.at(-1)!; + expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeLessThanOrEqual( + departure.getTime(), + ); + }); + }); + describe('fillRouteDay — day-level distribution', () => { const originYardId = 'yard-origin'; const destinationYardId = 'yard-dest'; 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 26f8749be..afe284206 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 @@ -42,7 +42,10 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; -import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; +import { + MAX_TEU_SLOTS_PER_WAGON, + containerWagonsForLines, +} from './wagon-plan.util'; import { Capacity, CorridorBudget, @@ -1014,17 +1017,22 @@ export class BookingBatchService implements OnModuleInit { return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; } - /** Fill one schedule from its priority-ordered pool until full. */ - async fillSchedule(scheduleId: string): Promise { + /** + * Fill one schedule from its priority-ordered pool until full. Returns the + * number of commercial units it RESERVED this pass (0 for government-only or + * no-fit passes) so a top-up caller can extend the payment phase only when a + * fresh pay window actually opened. + */ + async fillSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || !this.isFillable(schedule)) return; + if (!schedule || !this.isFillable(schedule)) return 0; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${scheduleId} has no locomotive/train set — skipped.`, ); - return; + return 0; } const rules = await this.loadGlobalRules(); @@ -1034,13 +1042,14 @@ export class BookingBatchService implements OnModuleInit { const budget = await this.remainingBudget(schedule, limits, wagonLengths); if (budget.maxRemaining().wagons <= 0) { await this.setWindow(scheduleId, "FULL"); - return; + return 0; } const pool = await this.bookingsRepository.findBatchPool(scheduleId); const units = this.groupConsolidatedPool(pool); let armed = false; let reservedThisPass = 0; + let commercialReserved = 0; // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when // reservations trickle instead of landing in one pass (a reserve() throwing @@ -1106,6 +1115,7 @@ export class BookingBatchService implements OnModuleInit { await this.reserve(booking, scheduleId); if (partner) await this.reserve(partner, scheduleId); armed = true; + commercialReserved += 1; } budget.subtract(need, leg); reservedThisPass += 1; @@ -1125,6 +1135,7 @@ export class BookingBatchService implements OnModuleInit { if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); + return commercialReserved; } /** @@ -1499,7 +1510,13 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, ); - await this.fillSchedule(scheduleId); + const topUpReserved = await this.fillSchedule(scheduleId); + // A top-up opened a fresh pay window for waiting bookings — push the + // schedule's PAYMENT phase out so the window tick's concludeCycle doesn't + // fire before those customers' new deadlines and expire them prematurely. + if (topUpReserved > 0) { + await this.extendPaymentPhaseForTopUp(scheduleId); + } } } @@ -1509,7 +1526,10 @@ export class BookingBatchService implements OnModuleInit { async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); await this.settleReserved(scheduleId, true); - await this.fillSchedule(scheduleId); + const topUpReserved = await this.fillSchedule(scheduleId); + if (topUpReserved > 0) { + await this.extendPaymentPhaseForTopUp(scheduleId); + } void this.triggerWagonAllocation(scheduleId); } @@ -1613,8 +1633,12 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); await this.expire(booking); - if (booking.trainScheduleId) - await this.fillSchedule(booking.trainScheduleId); + if (booking.trainScheduleId) { + const topUpReserved = await this.fillSchedule(booking.trainScheduleId); + if (topUpReserved > 0) { + await this.extendPaymentPhaseForTopUp(booking.trainScheduleId); + } + } } // ---- intercity ride-along API --------------------------------------------- @@ -1671,6 +1695,28 @@ export class BookingBatchService implements OnModuleInit { * so the engine sets it as it picks the train. */ private async reserve(booking: Booking, scheduleId: string): Promise { + // Idempotency guard: a booking already reserved (pay window open) or already + // paid on THIS schedule must never be re-reserved — that would fire a second + // `payNow` and reset its deadline, the "asked to pay again after paying" + // symptom. Read fresh state (the in-memory `booking` may be stale from the + // pooled query). Only bookings not yet committed to this train pass through. + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: booking.id } }); + if ( + fresh && + fresh.trainScheduleId === scheduleId && + (fresh.status === "SELECTED_FOR_BATCH" || + fresh.status === "AWAITING_PAYMENT" || + fresh.status === "PAID" || + fresh.paymentStatus === "PAID") + ) { + this.logger.debug( + `[BATCH] reserve skipped for ${booking.reference} — already ` + + `${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`, + ); + return; + } const now = new Date(); const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); await this.bookingsRepository.update(booking.id, { @@ -2020,14 +2066,15 @@ export class BookingBatchService implements OnModuleInit { if (booking.wagonsRequired && booking.wagonsRequired > 0) { return Math.ceil(booking.wagonsRequired); } - const fromContainers = (booking.bookingContainers ?? []).reduce( - (sum, c) => sum + Number(c.quantity ?? 0), - 0, - ); - return Math.max( - DEFAULT_WAGONS_PER_BOOKING, - fromContainers || DEFAULT_WAGONS_PER_BOOKING, + // booking.wagonsRequired is NULL for most rows (only set on certain + // scheduling paths). Derive from the container lines, TEU-aware: two 20ft + // share one wagon (wagonsPerUnit = 0.5). The old fallback summed raw + // container QUANTITY, so 20×20ft counted as 20 wagons instead of 10 and + // wrongly filled the train. + const fromContainers = containerWagonsForLines( + booking.bookingContainers ?? [], ); + return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers); } /** What one booking consumes along all three capacity axes. */ @@ -2061,6 +2108,8 @@ export class BookingBatchService implements OnModuleInit { { maxPullWeightTons: Number(locomotive.maxPullWeightTons), maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), + overageToleranceTons: Number(locomotive.overageToleranceTons) || 0, + overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, { @@ -2256,6 +2305,45 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * A top-up reservation (settle freed capacity mid-cycle, so the next waiting + * booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's + * `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run + * concludeCycle — was frozen when the phase started. Without this, concludeCycle + * fires before the top-up customer's deadline and expires a booking that still + * had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment + * window from now, but never past departure. Only while the schedule is still + * in the PAYMENT phase (a reopened cycle manages its own phase). + */ + async extendPaymentPhaseForTopUp(scheduleId: string): Promise { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (!schedule || schedule.windowPhase !== "PAYMENT") return; + const windowMs = await this.paymentWindowMs(); + let target = new Date(Date.now() + windowMs); + if ( + schedule.scheduledDepartureDate && + target > schedule.scheduledDepartureDate + ) { + target = schedule.scheduledDepartureDate; + } + // Only ever push the deadline OUT, never pull it in. + if ( + schedule.paymentPhaseEndsAt && + schedule.paymentPhaseEndsAt.getTime() >= target.getTime() + ) { + return; + } + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { paymentPhaseEndsAt: target }); + this.logger.log( + `[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` + + `(top-up reservation opened a fresh pay window)`, + ); + } + private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts index 76ea00422..54bf7a181 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts @@ -15,7 +15,6 @@ const nw5: WagonType = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 3b00abd0a..6173da6b1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -4,6 +4,7 @@ import { buildBulkWagonPlan, buildContainerWagonPlan, buildMixedWagonPlan, + containerWagonsForLines, roundTons, type WagonPlanSlot, } from './wagon-plan.util'; @@ -43,11 +44,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n return Math.max(1, Math.ceil(weight / capacity)); } - const lineSlots = (booking.bookingContainers ?? []).reduce( - (sum, line) => sum + Number(line.wagonsRequired ?? 0), - 0, - ); - return Math.max(1, lineSlots); + // TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1 + // wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored + // fraction. Ceiling per line would over-count split 20ft lines. + return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); } export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index d72f3d311..82fb7bd05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -1,6 +1,7 @@ import { bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, + minLocomotiveLimits, } from './train-capacity.util'; describe('train-capacity.util', () => { @@ -38,4 +39,31 @@ describe('train-capacity.util', () => { ).toBe(28); expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); }); + + it('extends weight/length caps by the locomotive overage tolerance (fertilizer +90T example)', () => { + const pw2 = { lengthMeters: 17.066, capacityTons: 70 }; + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + [pw2], + ); + expect(derived.maxWeightTons).toBe(3590); + }); + + it('ignores overage tolerance when unset (strict cap, no behavior change)', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [{ lengthMeters: 14, capacityTons: 70 }], + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + + it('takes the weakest locomotive tolerance across a multi-locomotive set', () => { + const limits = minLocomotiveLimits([ + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 }, + ]); + expect(limits?.maxPullWeightTons).toBe(3500); + expect(limits?.overageToleranceTons).toBe(20); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 5ec385924..fb52ec40c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -7,6 +7,10 @@ export type WagonTypeDimensions = { export type LocomotiveLimits = { maxPullWeightTons: number; maxTrainLengthMeters: number; + /** Allowed deviation above maxPullWeightTons before scheduling blocks the train. */ + overageToleranceTons?: number | null; + /** Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. */ + overageToleranceMeters?: number | null; }; export type DerivedTrainCapacity = { @@ -29,14 +33,19 @@ export function deriveTrainCapacityFromLocomotive( wagonTypes: WagonTypeDimensions[], ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, ): DerivedTrainCapacity { - const maxWeightTons = Math.min( - Number(locomotive.maxPullWeightTons) || Infinity, - ruleCaps?.maxTrainWeightTons ?? Infinity, - ); - const maxLengthMeters = Math.min( - Number(locomotive.maxTrainLengthMeters) || Infinity, - ruleCaps?.maxTrainLengthMeters ?? Infinity, - ); + const overageTons = Number(locomotive.overageToleranceTons) || 0; + const overageMeters = Number(locomotive.overageToleranceMeters) || 0; + + const maxWeightTons = + Math.min( + Number(locomotive.maxPullWeightTons) || Infinity, + ruleCaps?.maxTrainWeightTons ?? Infinity, + ) + overageTons; + const maxLengthMeters = + Math.min( + Number(locomotive.maxTrainLengthMeters) || Infinity, + ruleCaps?.maxTrainLengthMeters ?? Infinity, + ) + overageMeters; const types = wagonTypes.length > 0 @@ -75,7 +84,10 @@ export const MAX_FALLBACK_LENGTH = 760; * across all assigned locomotives. Returns null when no locomotives are given. */ export function minLocomotiveLimits( - locomotives: Array>, + locomotives: Array< + Pick & + Partial> + >, ): LocomotiveLimits | null { if (!locomotives.length) return null; return { @@ -85,6 +97,13 @@ export function minLocomotiveLimits( maxTrainLengthMeters: Math.min( ...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity), ), + // Weakest locomotive's tolerance governs the set, same as its caps. + overageToleranceTons: Math.min( + ...locomotives.map((l) => Number(l.overageToleranceTons) || 0), + ), + overageToleranceMeters: Math.min( + ...locomotives.map((l) => Number(l.overageToleranceMeters) || 0), + ), }; } 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 0dba6b3de..7efbceca4 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 @@ -14,7 +14,6 @@ const nw5 = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, @@ -35,7 +34,6 @@ const cw3 = { name: 'Covered Wagon', capacityTons: 60, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['BULK'], isActive: true, supportsContainer: false, 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 2c901b1ff..3c75204a6 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 @@ -975,13 +975,19 @@ export class TrainSchedulingService { throw new BadRequestException('Schedule train set has no locomotives'); } // forceAssign lets staff overload the locomotive set knowingly — the - // validator has already surfaced it as a warning in that case. - if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) { + // validator has already surfaced it as a warning in that case. Each + // locomotive's overageToleranceTons/Meters extends the hard cap before that + // override is even needed (e.g. the fertilizer example's +90T deviation). + const weightCapWithOverage = + limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0); + const lengthCapWithOverage = + limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0); + if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) { + if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); @@ -2916,8 +2922,10 @@ export class TrainSchedulingService { } if ( setLimits && - (setLimits.maxPullWeightTons < totalWeightTons || - setLimits.maxTrainLengthMeters < totalLengthMeters) + (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < + totalWeightTons || + setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < + totalLengthMeters) ) { pushLimit([ 'Assigned locomotives cannot support the total train weight and length', @@ -2935,8 +2943,10 @@ export class TrainSchedulingService { if ( !inServiceLocomotives.some( (l) => - Number(l.maxPullWeightTons) >= totalWeightTons && - Number(l.maxTrainLengthMeters) >= totalLengthMeters, + Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= + totalWeightTons && + Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= + totalLengthMeters, ) ) { pushLimit(['No locomotive can support the total train weight and length']); @@ -2991,7 +3001,10 @@ export class TrainSchedulingService { maxTrainLengthMeters?: number; maxWagonsPerTrain?: number; }, - locomotive?: Pick, + locomotive?: Pick< + Locomotive, + 'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters' + >, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -3018,6 +3031,8 @@ export class TrainSchedulingService { { maxPullWeightTons: Number(locomotive.maxPullWeightTons), maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), + overageToleranceTons: Number(locomotive.overageToleranceTons) || 0, + overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, { @@ -3688,10 +3703,17 @@ export class TrainSchedulingService { if (locomotive.status !== 'AVAILABLE') { throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); } - if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + if ( + Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) < + totalWeightTons + ) { throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`); } - if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + if ( + Number(locomotive.maxTrainLengthMeters) + + (Number(locomotive.overageToleranceMeters) || 0) < + totalLengthMeters + ) { throw new BadRequestException( `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index 824c45e6e..e180b482b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -6,6 +6,7 @@ import { buildBulkWagonPlan, buildContainerWagonPlan, buildMixedWagonPlan, + containerWagonsForLines, expandBookingContainerUnits, expandContainerItems, roundTons, @@ -20,7 +21,6 @@ const nw5: WagonType = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, @@ -32,7 +32,6 @@ const cw3: WagonType = { name: 'Covered Wagon', capacityTons: 60, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['BULK'], isActive: true, supportsContainer: false, @@ -200,3 +199,60 @@ describe('wagon-plan.util', () => { expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); }); }); + +describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => { + const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ + quantity, + wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, + containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, + }); + + it('20×20ft = 10 wagons (not 20)', () => { + expect(containerWagonsForLines([line(20, 0.5)])).toBe(10); + }); + + it('38×20ft = 19 wagons', () => { + expect(containerWagonsForLines([line(38, 0.5)])).toBe(19); + }); + + it('2×20ft = 1 wagon', () => { + expect(containerWagonsForLines([line(2, 0.5)])).toBe(1); + }); + + it('odd 3×20ft = 2 wagons (single line ceils)', () => { + expect(containerWagonsForLines([line(3, 0.5)])).toBe(2); + }); + + it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => { + // per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3. + expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3); + }); + + it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => { + // per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2. + expect( + containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]), + ).toBe(2); + }); + + it('5×20ft + 2×40ft = 5 wagons', () => { + expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5); + }); + + it('21×40ft = 21 wagons', () => { + expect(containerWagonsForLines([line(21, 1)])).toBe(21); + }); + + it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => { + // No containerType relation loaded → use the stored (0.5-aware) fraction. + expect( + containerWagonsForLines([ + { quantity: 20, wagonsRequired: 10 } as never, + ]), + ).toBe(10); + }); + + it('empty line set = 0 wagons', () => { + expect(containerWagonsForLines([])).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 21dd7b985..0221144b3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -89,18 +89,38 @@ export function containersPerWagonFromType(wagonsPerUnit: number): number { return Math.max(1, Math.round(1 / wpu)); } -function lineWagonsRequired(line: { +type ContainerLine = { quantity?: number | null; wagonsRequired?: number | null; containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; -}): number { +}; + +/** + * RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit + * (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so + * the BOOKING total is ceiled once — ceiling per line over-counts a booking that + * splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). + */ +function lineWagonsRaw(line: ContainerLine): number { const qty = Number(line.quantity ?? 0); if (qty <= 0) return 0; const wpu = Number(line.containerType?.wagonsPerUnit); if (Number.isFinite(wpu) && wpu > 0) { - return Math.ceil(qty * wpu); + return qty * wpu; } - return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1))); + // No wagonsPerUnit on the type: fall back to the line's stored fraction, else + // treat the whole line as one wagon. + const stored = Number(line.wagonsRequired); + return Number.isFinite(stored) && stored > 0 ? stored : 1; +} + +/** + * Whole wagons a set of container lines needs: ceil the summed RAW fraction so a + * half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0. + */ +export function containerWagonsForLines(lines: ContainerLine[]): number { + const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0); + return raw > 0 ? Math.ceil(raw) : 0; } /** @@ -110,15 +130,16 @@ export function buildContainerWagonPlan( bookings: Booking[], wagonType: WagonType, ): WagonPlanSlot[] { + // Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit + // can share a wagon with another 20ft of the SAME booking, never across + // bookings), then sum. Ceiling per line instead would over-count a booking + // that splits its 20ft units across several lines. const totalSlots = bookings.reduce((sum, booking) => { - const lineSlots = (booking.bookingContainers ?? []).reduce( - (lineSum, line) => lineSum + lineWagonsRequired(line), - 0, - ); - return sum + Math.max(lineSlots, 1); + const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []); + return sum + Math.max(bookingSlots, 1); }, 0); - const slots = Math.max(1, Math.ceil(totalSlots)); + const slots = Math.max(1, totalSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, wagonTypeId: wagonType.id, @@ -424,7 +445,7 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string export function validateTrainLimits( wagonPlan: WagonPlanSlot[], - wagonType: WagonType, + wagonType: Pick, limits?: TrainLimitConfig, ): string[] { const violations: string[] = []; @@ -478,7 +499,7 @@ export function validateMixedTrainLimits( return validateTrainLimits( wagonPlan, - { maxWagonsPerTrain } as WagonType, + { lengthMeters: minWagonLength }, { ...limits, maxWagonsPerTrain }, ); } diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts index 6de5debda..33fa0bcb0 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -3,7 +3,6 @@ import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, - IsInt, IsNumber, IsOptional, IsString, @@ -14,9 +13,6 @@ import { const toNumber = ({ value }: { value: unknown }) => value === '' || value == null ? value : Number(value); -const toOptionalNumber = ({ value }: { value: unknown }) => - value === '' || value == null ? undefined : Number(value); - const toBoolean = ({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; if (value === 'true') return true; @@ -60,13 +56,6 @@ export class CreateWagonTypeDto { @Min(0.001) lengthMeters!: number; - @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 }) - @IsOptional() - @Transform(toOptionalNumber) - @IsInt() - @Min(1) - maxWagonsPerTrain?: number; - @ApiPropertyOptional({ description: 'Supported load types, e.g. CONTAINER,BULK', type: [String], diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts index 2181a2bd1..e418460c4 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -19,9 +19,6 @@ export class WagonType extends BaseEntity { @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) lengthMeters!: number; - @Column({ name: 'max_wagons_per_train', type: 'int', nullable: true }) - maxWagonsPerTrain?: number | null; - @Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' }) supportedLoadTypes!: string[]; diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index ec69bb76a..79cecf659 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -80,7 +80,6 @@ export class WagonTypesService { name: dto.name.trim(), capacityTons: dto.capacityTons, lengthMeters: dto.lengthMeters, - maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? [], isActive: dto.isActive ?? true, }); @@ -101,8 +100,6 @@ export class WagonTypesService { ...dto, ...(nextCode ? { code: nextCode } : {}), ...(dto.name ? { name: dto.name.trim() } : {}), - maxWagonsPerTrain: - dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? undefined, }); diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index 9aa9e169c..4b3479327 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -227,7 +227,6 @@ async function ensureReferences(manager: any) { name: 'Gate Pass Demo Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, equatedLengthM: 14, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 3ebcca6ab..35e098f91 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -102,7 +102,6 @@ async function main() { name: 'Negad Demo Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, equatedLengthM: 14, diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index c42d831bc..570036f24 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -211,7 +211,6 @@ export class DemoBookingsSeeder { name: "Flat Wagon", capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ["CONTAINER"], isActive: true, equatedLengthM: 14, @@ -224,7 +223,6 @@ export class DemoBookingsSeeder { name: "Covered Hopper", capacityTons: 60, lengthMeters: 12, - maxWagonsPerTrain: 55, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 12, @@ -236,7 +234,6 @@ export class DemoBookingsSeeder { name: "Powder Wagon", capacityTons: 55, lengthMeters: 12, - maxWagonsPerTrain: 55, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 12, @@ -248,7 +245,6 @@ export class DemoBookingsSeeder { name: "Open Wagon", capacityTons: 65, lengthMeters: 13, - maxWagonsPerTrain: 53, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 13, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index c8cbaa453..1f43f322c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -536,7 +536,6 @@ export function WagonTypesCrudPage() { name: '', capacityTons: 0, lengthMeters: 0, - maxWagonsPerTrain: '', supportedLoadTypes: '', isActive: true, }); @@ -587,7 +586,6 @@ export function WagonTypesCrudPage() { name: '', capacityTons: 0, lengthMeters: 0, - maxWagonsPerTrain: '', supportedLoadTypes: '', isActive: true, }); @@ -601,7 +599,6 @@ export function WagonTypesCrudPage() { name: type.name ?? '', capacityTons: type.capacityTons ?? 0, lengthMeters: type.lengthMeters ?? 0, - maxWagonsPerTrain: type.maxWagonsPerTrain ?? '', supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '', isActive: type.isActive, }); @@ -814,12 +811,6 @@ export function WagonTypesCrudPage() { error={fieldErrors.lengthMeters} onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))} /> - setForm((current) => ({ ...current, maxWagonsPerTrain: value }))} - /> = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + TRUCK_ASSIGNED: { + stage: 3, + icon: Truck, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Truck assigned · preparing for pickup", + step: "edr-green.5", + badgeLabel: "Truck assigned", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, IN_TRANSIT: { stage: 3, icon: Truck, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index a023b7850..81f727b29 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -16,6 +16,7 @@ import type { Freight } from "@edr/types"; import { invoicesService, type PortalInvoice } from "@/services/invoices.service"; import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui"; +import { paymentStatusLabel } from "@/pages/bookings/booking-display"; import { saveBlob } from "@/utils/download"; import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; @@ -262,7 +263,7 @@ export function BookingPaymentPanel({ ? "Paid" : showCountdown ? "Pay window open" - : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} + : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx index fc308891d..1ad53c0c8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; +import { paymentStatusLabel } from "@/pages/bookings/booking-display"; + import { fmtDate, yardLabel } from "../utils"; import { SectionCard } from "./layout"; @@ -38,12 +40,7 @@ function Fact({ label, value }: { label: string; value: ReactNode }) { export function KeyFactsStrip({ booking }: { booking: BookingLike }) { const isContract = booking.bookingType === "GENERAL_CONTRACT"; const freight = booking.freightType === "BULK" ? "Bulk" : "Container"; - const payment = booking.paymentStatus - ? booking.paymentStatus - .replace(/_/g, " ") - .toLowerCase() - .replace(/^\w/, (c) => c.toUpperCase()) - : "—"; + const payment = paymentStatusLabel(booking.paymentStatus); return ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx index 6958a12dc..b5494ab9d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx @@ -13,6 +13,8 @@ import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; +import { bookingStatusLabel } from "@/pages/bookings/booking-display"; + import { bookingSubtitle, isDraftLike, isNegative } from "../utils"; export interface PageHeaderMenuActions { @@ -60,7 +62,7 @@ export function PageHeader({ className="shrink-0 rounded-full" style={{ width: 7, height: 7, backgroundColor: dotColor }} /> - {status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())} + {bookingStatusLabel(status)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx index 52451028d..7a6323a58 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; +import { bookingStatusLabel } from "@/pages/bookings/booking-display"; + import { fmtDate, isDraftLike, isNegative } from "../utils"; import { CardTitle, SectionCard } from "./layout"; @@ -15,10 +17,7 @@ function StatusPill({ status }: { status: string }) { const color = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D"; const bg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1"; const border = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD"; - const label = status - .replace(/_/g, " ") - .toLowerCase() - .replace(/\b\w/g, (m) => m.toUpperCase()); + const label = bookingStatusLabel(status); return ( m.toUpperCase()); +} + +/** + * Human label for a booking status. Single source of truth is the booking + * list's `STATUS_CONFIG` badge labels; anything not mapped there falls back to + * a readable title-cased form (never the raw SCREAMING_SNAKE enum). + */ +export function bookingStatusLabel(status?: string | null): string { + if (!status) return "—"; + return STATUS_CONFIG[status]?.badgeLabel ?? titleCaseStatus(status); +} + type BookingLike = Freight.IBooking & { bookingType?: string; freightType?: string; @@ -59,7 +79,12 @@ const PAYMENT_COLORS: Record = { PAID: "green", PENDING: "gray", PNR_GENERATED: "blue", + // Backend emits the long form on some flows; keep the short alias too. VERIFICATION_IN_PROGRESS: "yellow", + PAYMENT_VERIFICATION_IN_PROGRESS: "yellow", + OVERDUE: "red", + REFUNDED: "blue", + CANCELLED: "gray", FAILED: "red", }; @@ -68,9 +93,19 @@ const PAYMENT_LABELS: Record = { PENDING: "Pending", PNR_GENERATED: "PNR generated", VERIFICATION_IN_PROGRESS: "Verifying", + PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying", + OVERDUE: "Overdue", + REFUNDED: "Refunded", + CANCELLED: "Cancelled", FAILED: "Failed", }; +/** Human label for a payment status (plain text, no badge). */ +export function paymentStatusLabel(status?: string | null): string { + if (!status) return "—"; + return PAYMENT_LABELS[status] ?? titleCaseStatus(status); +} + /** Payment status pill. */ export function PaymentBadge({ status }: { status?: string | null }) { if (!status) return —; @@ -81,7 +116,7 @@ export function PaymentBadge({ status }: { status?: string | null }) { color={PAYMENT_COLORS[status] ?? "gray"} styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }} > - {PAYMENT_LABELS[status] ?? status.replace(/_/g, " ")} + {PAYMENT_LABELS[status] ?? titleCaseStatus(status)} ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx index 51125194c..524f3804e 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractBookingWindowsSection.tsx @@ -32,6 +32,28 @@ const TZ = "Africa/Addis_Ababa"; /** Cards visible per carousel page. */ const PER_PAGE = 3; +/** Customer-facing labels for a booking-window phase / status. */ +const WINDOW_PHASE_LABELS: Record = { + PRE_WINDOW: "Opens soon", + OPEN: "Open now", + DOC_REVIEW: "Document review", + PAYMENT: "Payment due", + DONE: "Closed", + CLOSED_FOR_DAY: "Closed for the day", +}; + +/** Friendly label for a window phase/status, never the raw enum. */ +function windowPhaseLabel(phase?: string | null): string { + if (!phase) return "—"; + return ( + WINDOW_PHASE_LABELS[phase] ?? + phase + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()) + ); +} + function fmtDay(iso: string): string { return new Date(iso).toLocaleDateString("en-GB", { weekday: "short", @@ -60,7 +82,7 @@ function windowLabel(w: MyBookingWindow): string { if (w.windowOpensAt) { return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`; } - return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " "); + return windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus); } /** @@ -163,7 +185,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) { > {open ? "Open now" - : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} + : windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx index cc5318cf9..26eceab8e 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; +import { bookingStatusLabel } from "@/pages/bookings/booking-display"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone"; import { contractsService } from "@/services/contracts.service"; @@ -99,7 +100,7 @@ export function ContractClearanceWorkflowBanner({ Global Logistics has created your shipment booking {clearance.linkedBookingStatus - ? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})` + ? ` (${bookingStatusLabel(clearance.linkedBookingStatus).toLowerCase()})` : ""} . Track its progress from the booking. diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 8f045861c..2bb3561cb 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -85,6 +85,37 @@ const CLEARANCE_UPLOAD_STATUSES = [ type ContractFile = NonNullable[number]; +// Customer-facing labels for a shipment-request status (BOOKING_REQUEST_STATUSES). +const BOOKING_REQUEST_STATUS_LABELS: Record = { + PENDING: "Pending review", + ACCEPTED: "Accepted", + REJECTED: "Rejected", + CANCELLED: "Cancelled", +}; + +// Customer-facing labels for an invoice status (Freight.InvoiceStatus). +const INVOICE_STATUS_LABELS: Record = { + DRAFT: "Draft", + ISSUED: "Issued", + PENDING: "Due", + PARTIALLY_PAID: "Partially paid", + PAID: "Paid", + OVERDUE: "Overdue", + CANCELLED: "Cancelled", + REFUNDED: "Refunded", + EXPIRED: "Expired", +}; + +function invoiceStatusLabel(status: string): string { + return ( + INVOICE_STATUS_LABELS[status] ?? + status + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()) + ); +} + // Business-license document codes — surfaced as their own section so they stand // out from the rest of the onboarding/profile set. const BUSINESS_LICENSE_DOC_CODES = new Set([ @@ -667,7 +698,8 @@ export default function ContractDetailPage() { variant="filled" radius="sm" > - {clearanceView.riskLevel} + {clearanceView.riskLevel.charAt(0) + + clearanceView.riskLevel.slice(1).toLowerCase()} {clearanceView.riskAssignedAt ? ( @@ -1226,12 +1258,15 @@ export default function ContractDetailPage() { ? "teal" : req.status === "REJECTED" ? "red" - : "yellow" + : req.status === "CANCELLED" + ? "gray" + : "yellow" } > {req.status === "ACCEPTED" && req.createdBookingId ? "Booking created" - : req.status} + : BOOKING_REQUEST_STATUS_LABELS[req.status] ?? + req.status} {req.createdBookingId ? ( @@ -1722,7 +1757,7 @@ function FinalInvoiceDueCard({ {invoice.invoiceNumber} - {invoice.status} + {invoiceStatusLabel(invoice.status)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index e2c7bffcc..77086103b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1151,6 +1151,9 @@ function ContainerLineEditor({ render={({ field, fieldState }) => ( + field.onChange(e.currentTarget.value.toUpperCase()) + } label={u === 0 ? "Container number *" : undefined} placeholder="e.g. MSCU1234567" error={fieldState.error?.message} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index 565ca2079..d8d6553ba 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -149,21 +149,35 @@ export const CONTRACT_STATUS_CONFIG: Record< DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", ...TONE.info }, CLEARANCE_READY: { label: "Clearance Ready", ...TONE.success }, OPERATION_REQUEST_PENDING: { label: "Operation Review", ...TONE.warning }, + OPERATION_REQUESTED: { label: "Operation Requested", ...TONE.info }, OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", ...TONE.warning }, OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", ...TONE.warning }, ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", ...TONE.warning }, + READY_FOR_ASSIGNMENT: { label: "Assigning Wagon", ...TONE.info }, + WAGON_ASSIGNED: { label: "Wagon Assigned", ...TONE.success }, SELECTED_FOR_BATCH: { label: "Awaiting Payment", ...TONE.warning }, PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning }, + PAYMENT_VERIFICATION_IN_PROGRESS: { label: "Verifying Payment", ...TONE.warning }, + INVOICED: { label: "Invoiced", ...TONE.info }, PAID: { label: "Paid", ...TONE.success }, + PENDING_CONSOLIDATION: { label: "Consolidating", ...TONE.info }, + CONSOLIDATED: { label: "Consolidated", ...TONE.success }, + TRUCK_ASSIGNED: { label: "Truck Assigned", ...TONE.success }, IN_TRANSIT: { label: "In Transit", ...TONE.info }, ARRIVED: { label: "Arrived", ...TONE.success }, + DELIVERED: { label: "Delivered", ...TONE.success }, COMPLETED: { label: "Completed", ...TONE.success }, }; export function ContractStatusBadge({ status }: { status: string }) { const cfg = CONTRACT_STATUS_CONFIG[status] ?? { - label: status, + // Readable title-case fallback so an unmapped status never leaks the raw + // SCREAMING_SNAKE enum to the customer. + label: status + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()), color: MUTED, bg: "#EEF2F6", }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 1403cf1b5..a21a69a76 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -26,8 +26,17 @@ export interface ShipmentValidationContext { requiresDate?: boolean; } +// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit. +const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; + const containerUnitSchema = z.object({ - containerNumber: z.string().min(1, "Container number is required."), + containerNumber: z + .string() + .min(1, "Container number is required.") + .refine( + (v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()), + "Enter a valid ISO container number (e.g. ABCD1234567).", + ), sealNumber: z.string().default(""), vgmTons: z .string() @@ -68,6 +77,18 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { } if (ctx.isContainer) { + // Container numbers must be unique within this shipment (front-end only — + // the DB column is intentionally not unique). Duplicates block submit and + // price generation since both run through this same schema validation. + const numberCounts = new Map(); + data.containers.forEach((line) => { + line.units.forEach((u) => { + const key = u.containerNumber.trim().toUpperCase(); + if (!key) return; + numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1); + }); + }); + data.containers.forEach((line, i) => { const qty = Number(line.quantity || 0); if (qty >= 1 && line.units.length < qty) { @@ -77,6 +98,16 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { message: `Enter details for all ${qty} container(s).`, }); } + line.units.forEach((u, j) => { + const key = u.containerNumber.trim().toUpperCase(); + if (key && (numberCounts.get(key) ?? 0) > 1) { + refineCtx.addIssue({ + code: "custom", + path: ["containers", i, "units", j, "containerNumber"], + message: "Duplicate container number in this shipment.", + }); + } + }); if (ctx.isHazardous) { const h = Number(line.hazardousQuantity || 0); if (h > qty) { From 14a5861ba3218b88a9be1475981627a2b69a1dda Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 03:46:31 +0000 Subject: [PATCH 02/31] resolve merge conflict --- .../src/pages/contracts/NewShipmentPage.tsx | 5 ----- .../pages/contracts/new-shipment-form/schema.ts | 15 --------------- 2 files changed, 20 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 2f0681590..77086103b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1151,14 +1151,9 @@ function ContainerLineEditor({ render={({ field, fieldState }) => ( field.onChange(e.currentTarget.value.toUpperCase()) } -======= - onChange={(e) => field.onChange(e.currentTarget.value.toUpperCase())} - maxLength={11} ->>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb label={u === 0 ? "Container number *" : undefined} placeholder="e.g. MSCU1234567" error={fieldState.error?.message} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 245f10475..a21a69a76 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -26,31 +26,16 @@ export interface ShipmentValidationContext { requiresDate?: boolean; } -<<<<<<< HEAD // ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit. const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; -======= -// ISO 6346: 4 letters (owner + category) + 6 serial digits + 1 check digit. -// Enforced at booking input so the number stays a clean reference in the warehouse. -const ISO_CONTAINER_RE = /^[A-Z]{4}\d{7}$/; ->>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb const containerUnitSchema = z.object({ containerNumber: z .string() -<<<<<<< HEAD .min(1, "Container number is required.") .refine( (v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()), "Enter a valid ISO container number (e.g. ABCD1234567).", -======= - .transform((v) => v.trim().toUpperCase()) - .pipe( - z - .string() - .min(1, "Container number is required.") - .regex(ISO_CONTAINER_RE, "Use ISO format: 4 letters + 7 digits, e.g. ABCU1234567."), ->>>>>>> 1b9ac42ed3fc9bd6b8dea8d08b5c4c066d083feb ), sealNumber: z.string().default(""), vgmTons: z From b98bc529da4773728774d0ea6a533c46a89934a7 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 04:02:54 +0000 Subject: [PATCH 03/31] Add minDate handling for vessel and departure dates across components --- .../portal/src/pages/contracts/new-contract-form/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index b0a9c0a0d..6ffa29b8c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -286,7 +286,7 @@ export const initialContractFormValues: DeepPartial = { customsClearingAgent: "", cargoType: "container", - enabledContainerSizes: ["20ft"], + enabledContainerSizes: [], containerSizeCaps: {}, cargoTypePath: [], cargoFreeText: "", From df7d7967415473b563c4cbaaab9458a789e7b954 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 04:06:40 +0000 Subject: [PATCH 04/31] Add minDate handling for vessel and departure dates across components --- .../contracts/ExportClearanceStepper.tsx | 9 ++++++++ .../contracts/GlClearanceUploadModal.tsx | 11 +++++++++- .../trainScheduling/EditScheduleDateModal.tsx | 14 +++++++++++- .../trainScheduling/RescheduleTrainDialog.tsx | 22 ++++++++++++++++++- .../TrainScheduleV2ListPage.tsx | 21 ++++++++++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index dc59c4dfc..c94e005ae 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -50,6 +50,13 @@ import { import { contractsService } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; +/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */ +function todayISODate(): string { + const now = new Date(); + const tz = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - tz).toISOString().slice(0, 10); +} + /** * Export customs flow, ordered per the stakeholder process: * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) @@ -937,6 +944,7 @@ export function ReleaseOrderCard({ ); const [loading, setLoading] = useState(false); const [amendLoading, setAmendLoading] = useState(false); + const minVesselDate = useMemo(todayISODate, []); return ( @@ -949,6 +957,7 @@ export function ReleaseOrderCard({ label="Vessel departure date" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={minVesselDate} size="sm" /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index 87a8993ee..1953796dd 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { DateInput } from "@mantine/dates"; import { Ship, Upload } from "lucide-react"; @@ -40,6 +40,13 @@ export function GlClearanceUploadModal({ vesselDepartureDate ? new Date(vesselDepartureDate) : null, ); const [loading, setLoading] = useState(false); + // Earliest selectable vessel date (today, local) — refreshed on each open. + const todayISODate = useMemo(() => { + if (!opened) return undefined; + const now = new Date(); + const tz = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - tz).toISOString().slice(0, 10); + }, [opened]); const isDo = kind === "do"; const isRo = kind === "ro"; @@ -115,6 +122,7 @@ export function GlClearanceUploadModal({ label="Vessel departure date" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={todayISODate} size="sm" required /> @@ -123,6 +131,7 @@ export function GlClearanceUploadModal({ label="Vessel arrival date (optional)" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={todayISODate} size="sm" clearable /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx index 6ea7dfc5b..c173614dc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx @@ -67,9 +67,13 @@ export default function EditScheduleDateModal({ ); const [value, setValue] = useState(""); + // Earliest selectable departure, refreshed each time the modal opens. + const [minValue, setMinValue] = useState(""); useEffect(() => { - if (opened) setValue(toLocalInputValue(currentDate)); + if (!opened) return; + setValue(toLocalInputValue(currentDate)); + setMinValue(toLocalInputValue(new Date().toISOString())); }, [opened, currentDate]); const handleSave = async () => { @@ -77,6 +81,13 @@ export default function EditScheduleDateModal({ toast({ title: "Pick a departure date", variant: "destructive" }); return; } + if (new Date(value).getTime() < Date.now()) { + toast({ + title: "Departure date must be in the future", + variant: "destructive", + }); + return; + } try { await save.mutateAsync({ id: scheduleId, @@ -124,6 +135,7 @@ export default function EditScheduleDateModal({ setValue(e.currentTarget.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx index d0f670e51..271603bca 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx @@ -1,9 +1,19 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core"; import toast from "react-hot-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; +/** `min` for a `datetime-local` input: now, in the browser's local zone. */ +function nowLocalDateTime(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + + `T${pad(now.getHours())}:${pad(now.getMinutes())}` + ); +} + export function RescheduleTrainDialog({ scheduleId, currentBookingIds, @@ -20,12 +30,21 @@ export function RescheduleTrainDialog({ const [newDepartureDate, setNewDepartureDate] = useState(""); const [reason, setReason] = useState(""); const [loading, setLoading] = useState(false); + // Earliest selectable departure, refreshed each time the dialog opens. + const minDepartureDate = useMemo( + () => (opened ? nowLocalDateTime() : ""), + [opened], + ); const handleSubmit = async () => { if (!newDepartureDate) { toast.error("Select a new departure date"); return; } + if (new Date(newDepartureDate).getTime() < Date.now()) { + toast.error("New departure must be in the future"); + return; + } setLoading(true); try { await trainSchedulingService.maintenanceReschedule(scheduleId, { @@ -53,6 +72,7 @@ export function RescheduleTrainDialog({ setNewDepartureDate(e.target.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 14432d8ed..859efece1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -55,6 +55,13 @@ import { useToast } from "@/hooks/use-toast"; import type { TrainScheduleListItem } from "@/types/trainScheduling"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; +/** `min` for a `datetime-local` input: now, in the browser's local zone. */ +const nowLocalDateTime = () => { + const now = new Date(); + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); + return now.toISOString().slice(0, 16); +}; + const splitDate = (value?: string | null) => { if (!value) return { day: "—", time: "" }; const date = new Date(value); @@ -103,6 +110,12 @@ export default function TrainScheduleV2ListPage() { const [routeId, setRouteId] = useState(""); const [scheduleDate, setScheduleDate] = useState(""); const [locomotiveIds, setLocomotiveIds] = useState([]); + // Recomputed each time the create modal opens so a long-lived tab can't keep + // offering a stale "now" as the earliest selectable departure. + const minScheduleDate = useMemo( + () => (createOpen ? nowLocalDateTime() : ""), + [createOpen], + ); const schedulesQuery = useQuery( api.trainScheduling.scheduleList.queryOptions({ input: {} }), @@ -443,6 +456,13 @@ export default function TrainScheduleV2ListPage() { }); return; } + if (new Date(scheduleDate).getTime() < Date.now()) { + toast({ + title: "Departure date must be in the future", + variant: "destructive", + }); + return; + } try { const created = await create.mutateAsync({ payload: { @@ -691,6 +711,7 @@ export default function TrainScheduleV2ListPage() { setScheduleDate(e.currentTarget.value)} /> From bedcdca78be4c0b791c8c10f563b77ee4c2c1cea Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 04:24:08 +0000 Subject: [PATCH 05/31] Enforce non-negative values for numeric inputs across various components --- .../train-scheduling.service.ts | 118 ++---------------- .../ruleEngine/RuleEngineFormDialog.tsx | 14 ++- .../BookingWindowSettingsModal.tsx | 2 + .../trainScheduling/DurationField.tsx | 1 + .../TrainSchedulingGlobalRulesPage.tsx | 7 ++ .../new-booking-form/step5-cargo-details.tsx | 15 ++- .../src/pages/contracts/NewShipmentPage.tsx | 19 ++- .../contracts/new-shipment-form/schema.ts | 35 +++++- 8 files changed, 92 insertions(+), 119 deletions(-) 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 f858c6c14..79b009d79 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 @@ -17,7 +17,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm'; +import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -43,8 +43,6 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { CargoType } from '../rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -3446,37 +3444,6 @@ export class TrainSchedulingService { return wagonType; } - /** - * Soft wagon-type resolution for the customer-facing availability preview - * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type; - * returns null (→ "no days") instead of throwing when nothing is configured, - * since this only estimates which days have wagons and creates no booking. - */ - private async resolveWagonTypeForPreview( - freightType: 'CONTAINER' | 'BULK', - cargoTypeCode: string | null, - ): Promise { - if (freightType === 'BULK') { - if (!cargoTypeCode) return null; - const cargoType = await this.dataSource.getRepository(CargoType).findOne({ - where: { code: cargoTypeCode }, - relations: { wagonType: true }, - }); - return cargoType?.wagonType?.isActive ? cargoType.wagonType : null; - } - - // Container preview: the input carries no specific container type, so use the - // wagon type of the first configured (active) container type. - const containerType = await this.dataSource - .getRepository(ContainerType) - .findOne({ - where: { isActive: true, wagonTypeId: Not(IsNull()) }, - relations: { wagonType: true }, - order: { displayOrder: 'ASC' }, - }); - return containerType?.wagonType?.isActive ? containerType.wagonType : null; - } - /** * Stamp each plan slot with the leg it occupies (dynamic consist): the * boarding/alighting yards of the bookings it carries. Null means the @@ -4237,13 +4204,14 @@ export class TrainSchedulingService { } /** - * Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given - * cargo. A day is selectable only when ≥1 OPEN schedule on the route that day - * has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that - * schedule's origin yard, and (b) remaining train capacity (not fully - * allocated). Days with trains but not enough matching wagons are excluded. - * Same `{ days: string[] }` shape as getAvailableDays — the customer still - * picks a DAY, not a train. + * Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day + * is selectable when ≥1 OPEN schedule on the route that day still has remaining + * train capacity (not fully allocated). Wagon availability is deliberately NOT + * checked here: whether a matching wagon currently sits in the right yard is an + * operational question staff resolve when they approve or reject the booking, + * not something the customer can act on while choosing a date. Same + * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY, + * not a train. */ async getAvailableDaysForCargo(input: { originYardId?: string; @@ -4259,85 +4227,17 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; - // Resolve the wagon type this cargo needs via the cargo/container-type FK. - // Soft (customer availability preview): no days if unresolved, never throws. - const requiredType = await this.resolveWagonTypeForPreview( - input.freightType, - input.cargoTypeCode ?? null, - ); - if (!requiredType) return { days: [] }; - - // How many wagons of that type the cargo needs. - const slotsNeeded = this.wagonsNeededForCargo(input, requiredType); - void slotsNeeded; // TEMP: unused while the wagon-availability filter is off. - - // TEMP (per request): wagon-availability filtering is DISABLED. A day is now - // offered whenever a bookable schedule that day has remaining train capacity - // — regardless of whether matching wagons are actually available at the - // origin / boarding yard. This surfaces days even when no wagon is on hand. - // Restore the block below to bring back the "enough matching wagons" gate. - // - // // AVAILABLE wagons of the required type, counted once per origin yard. - // const availableByYard = new Map(); - // const availableAt = async (yardId: string): Promise => { - // const cached = availableByYard.get(yardId); - // if (cached !== undefined) return cached; - // const counts = await this.countFleetAvailability(yardId); - // const n = - // counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; - // availableByYard.set(yardId, n); - // return n; - // }; - const days = new Set(); for (const s of schedules) { const hasCapacity = Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; if (!hasCapacity) continue; - // TEMP (per request): wagon-availability check commented out — see note - // above. Dynamic consist: wagons may ride from the train's origin OR - // already sit at the booking's own boarding yard and attach when the train - // arrives — either pool can serve a sub-corridor booking. - // let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; - // if ( - // !enoughWagons && - // input.originYardId && - // input.originYardId !== s.originStationId - // ) { - // enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded; - // } - // if (!enoughWagons) continue; if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } return { days: [...days].sort() }; } - /** - * Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight / - * capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per - * wagon. Mirrors wagon-plan.util without fabricating Booking entities. - */ - private wagonsNeededForCargo( - input: { - freightType: 'CONTAINER' | 'BULK'; - totalWeightTons?: number; - containers?: Array<{ containerSize: string; quantity: number }>; - }, - wagonType: WagonType, - ): number { - if (input.freightType === 'BULK') { - const capacity = Number(wagonType.capacityTons) || 1; - const weight = Number(input.totalWeightTons ?? 0); - return Math.max(1, Math.ceil(weight / capacity)); - } - const teu = (input.containers ?? []).reduce((sum, c) => { - const per = c.containerSize === '40ft' ? 2 : 1; - return sum + per * Math.max(0, Number(c.quantity ?? 0)); - }, 0); - return Math.max(1, Math.ceil(teu / 2)); - } - /** * Ordered stop yards of a schedule's route: origin → milestones → destination, * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 8553bcf0b..403a98f1b 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -307,13 +307,23 @@ const RuleEngineFormDialog = ({ ); } + const isNumber = field.type === "number"; + return ( setField(field.name, e.currentTarget.value)} + onChange={(e) => { + const next = e.currentTarget.value; + if (isNumber && next.trim().startsWith("-")) return; + setField(field.name, next); + }} placeholder={field.placeholder} required={field.required} size="md" diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index ec3774ce6..1bfa392bc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({ } min={1} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> ) : ( @@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({ } min={0} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> )} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx index 06c9013d4..9c3c54f3c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -98,6 +98,7 @@ export default function DurationField({ emitNative(v === "" ? "" : Number(v), unit) } clampBehavior="none" + allowNegative={false} allowDecimal min={min != null ? convert(min, nativeUnit, unit) : 0} disabled={disabled} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index c71235640..0186d5acb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -107,6 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainLengthMeters: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -119,6 +120,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainWeightTons: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -130,6 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxWagonsPerTrain: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -145,6 +148,7 @@ export default function TrainSchedulingGlobalRulesPage() { })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0.001} disabled={loading} @@ -160,6 +164,7 @@ export default function TrainSchedulingGlobalRulesPage() { })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} disabled={loading} @@ -203,6 +208,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, windowOpenHour: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} max={23} @@ -216,6 +222,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, windowCloseHour: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} max={23} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index ac336c47c..b582468cf 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, type KeyboardEvent } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { Flame, Package, Plus, Snowflake, Trash2, Weight } from "lucide-react"; import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core"; @@ -26,6 +26,15 @@ type BookingForm = UseFormReturn< BookingFormValues >; +/** + * Every quantity on this step is a non-negative magnitude. A native number + * input's `min` only constrains its stepper, so swallow the minus key before it + * can put a negative into the field at all. + */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + /** * One numbered toggle per container unit in the line — tap units to mark how * many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection @@ -379,6 +388,7 @@ export function Step5CargoDetails({ }} id="cargoWeight" type="number" + onKeyDown={blockNegative} label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"} placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"} leftSection={ @@ -435,6 +445,7 @@ export function Step5CargoDetails({ render={({ field: hq, fieldState }) => ( ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 77086103b..c36a74255 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -58,6 +58,15 @@ type ShipmentForm = ReturnType< typeof useForm >; +/** + * Every quantity on this form is a non-negative magnitude. A native number + * input's `min` only constrains its stepper, so swallow the minus key before it + * can put a negative into the field at all. + */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + export default function NewShipmentPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -962,6 +971,7 @@ function CargoStep({ qty) { + if (h < 0) { + refineCtx.addIssue({ + code: "custom", + path: ["containers", i, "hazardousQuantity"], + message: "Enter a valid hazardous quantity.", + }); + } else if (h > qty) { refineCtx.addIssue({ code: "custom", path: ["containers", i, "hazardousQuantity"], @@ -120,7 +126,13 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { } if (ctx.isReefer) { const r = Number(line.reeferQuantity || 0); - if (r > qty) { + if (r < 0) { + refineCtx.addIssue({ + code: "custom", + path: ["containers", i, "reeferQuantity"], + message: "Enter a valid refrigerated quantity.", + }); + } else if (r > qty) { refineCtx.addIssue({ code: "custom", path: ["containers", i, "reeferQuantity"], @@ -130,10 +142,21 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { } }); } else { - const bulkCap = - ctx.unitOfMeasure === "PER_ITEM" - ? Number(data.itemCount || 0) - : Number(data.cargoWeightTons || 0); + const isPerItem = ctx.unitOfMeasure === "PER_ITEM"; + const bulkCap = isPerItem + ? Number(data.itemCount || 0) + : Number(data.cargoWeightTons || 0); + + // The bulk cargo amount itself: a positive magnitude. Without this a + // negative (typed past the input's `min`) reaches the API unchecked. + const bulkPath = isPerItem ? "itemCount" : "cargoWeightTons"; + if (Number.isNaN(bulkCap) || bulkCap <= 0) { + refineCtx.addIssue({ + code: "custom", + path: [bulkPath], + message: "Enter a quantity greater than 0.", + }); + } const boundBulkPortion = ( on: boolean, From 1b2b3f68f86495f73779559b0534306b355f8000 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 06:05:34 +0000 Subject: [PATCH 06/31] Add tareWeightTons property to CreateWagonTypeDto and update related components --- ...0000000-MakeWagonTypeTareWeightRequired.ts | 63 +++ .../booking-batch.constants.ts | 10 + .../booking-batch.service.spec.ts | 49 +++ .../train-scheduling/booking-batch.service.ts | 372 +++++++++++++----- .../booking-window.service.ts | 12 + .../train-capacity.util.spec.ts | 209 +++++++--- .../train-scheduling/train-capacity.util.ts | 237 ++++++++--- .../train-scheduling.service.ts | 30 +- .../train-scheduling/wagon-plan.util.ts | 64 +-- .../wagon-types/dto/create-wagon-type.dto.ts | 11 + .../wagon-types/entities/wagon-type.entity.ts | 5 +- .../wagon-types/wagon-types.service.ts | 1 + .../src/pages/fleet/FleetCrudPages.tsx | 46 ++- .../src/pages/fleet/config/resources.ts | 9 + .../pages/trainScheduling/BatchBoardPage.tsx | 106 +++-- .../BatchScheduleDetailPage.tsx | 7 +- .../src/services/locomotives.service.ts | 4 + .../src/services/wagon-types.service.ts | 2 + .../backoffice/src/types/trainScheduling.ts | 5 +- 19 files changed, 979 insertions(+), 263 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts diff --git a/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts b/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts new file mode 100644 index 000000000..ff85f3f24 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Tare weight becomes mandatory on a wagon type. + * + * The locomotive's pull limit is a GROSS limit — it drags the wagon as well as + * the cargo — so capacity math cannot run without a tare. A NULL tare silently + * read as zero and let trains overbook by the tare fraction (~27% on a PW2 + * consist), so the column is now NOT NULL. + * + * Any row still missing a tare predates 2060000000000-SeedRailWagonTypes (which + * upserts the ten real EDR types). Backfill those by code first, and give any + * remaining custom/demo type the NW5 flat-wagon tare rather than fail the + * migration — a wrong-but-plausible tare is recoverable in the admin UI; a + * blocked deploy is not. + */ +export class MakeWagonTypeTareWeightRequired2070000000000 implements MigrationInterface { + private readonly tareByCode: Array<[string, number]> = [ + ['NW7', 37.1], + ['NW5', 22.4], + ['PW2', 25.2], + ['GW2', 23], + ['CW4', 24.8], + ['CW3', 23.4], + ['KW2', 25.2], + ['KW3', 24], + ['NW6', 25.3], + ['BW1', 32.1], + ]; + + /** NW5 flat wagon — the commonest type in the fleet (550 of 1100). */ + private readonly fallbackTareTons = 22.4; + + public async up(queryRunner: QueryRunner): Promise { + for (const [code, tareWeightTons] of this.tareByCode) { + await queryRunner.query( + `UPDATE freight.wagon_types + SET tare_weight_tons = $2 + WHERE code = $1 AND tare_weight_tons IS NULL;`, + [code, tareWeightTons], + ); + } + + await queryRunner.query( + `UPDATE freight.wagon_types + SET tare_weight_tons = $1 + WHERE tare_weight_tons IS NULL;`, + [this.fallbackTareTons], + ); + + await queryRunner.query( + `ALTER TABLE freight.wagon_types + ALTER COLUMN tare_weight_tons SET NOT NULL;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.wagon_types + ALTER COLUMN tare_weight_tons DROP NOT NULL;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index d8dc6b116..1ff1880b7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -24,3 +24,13 @@ export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14; /** Default CW3 covered wagon length for bulk bookings (m). */ export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14; + +/** + * Fallback tare weights (T) matching the length fallbacks above. The locomotive + * pull limit is a GROSS limit, so a booking's weight budget must include the + * empty weight of every wagon it occupies — not just its cargo. + */ +export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4; + +/** Default CW3 gondola tare for bulk bookings (T). */ +export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4; 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 6abd56b9f..6f59ca123 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 @@ -31,6 +31,7 @@ describe('BookingBatchService — PAID reconcile', () => { createMany: jest.Mock; }; let trainSchedulesRepository: { + findById: jest.Mock; findByIdWithFullGraph: jest.Mock; findAll: jest.Mock; }; @@ -65,6 +66,11 @@ describe('BookingBatchService — PAID reconcile', () => { createMany: jest.fn().mockResolvedValue(undefined), }; trainSchedulesRepository = { + findById: jest.fn().mockResolvedValue({ + id: scheduleId, + bookingWindowStatus: 'OPEN', + windowPhase: null, + }), findByIdWithFullGraph: jest.fn().mockResolvedValue({ id: scheduleId, maxWagons: 10, @@ -407,6 +413,49 @@ describe('BookingBatchService — PAID reconcile', () => { // Never reserved — waits for its partner in a later cycle. expect(notifier.payNow).not.toHaveBeenCalled(); }); + + it('clears a stale FULL flag and fills a train whose bookings all expired', async () => { + // The deadlock: train A filled once, every booking then expired, but + // bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever + // reads the budget, so the batch skipped the train forever — it just cycled + // PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd + // already-pinned booking got settled, one per cycle. + const staleFull = { + id: trainA, + maxWagons: 1, + bookingWindowStatus: 'FULL', + // The batch runs while the customer window is closed. + windowPhase: 'PAYMENT', + direction: 'IMPORT', + trainSetId: `set-${trainA}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + originStationId: originYardId, + destinationStationId: destinationYardId, + }; + trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]); + // Live capacity says the train is empty: 1 free wagon, nothing allocated. + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull); + // refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase); + // the re-read reports it, and isFillable() admits CLOSED during PAYMENT. + trainSchedulesRepository.findById.mockResolvedValue({ + id: trainA, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + }); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ + commercial('waiting', 30), + ]); + + const touched = await service.fillRouteDay(originYardId, destinationYardId, day); + + // The train was reopened to the batch and actually filled, not skipped. + expect(touched).toEqual([trainA]); + expect(notifier.payNow).toHaveBeenCalledTimes(1); + expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting'); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); }); describe('expireUnacceptedForRouteDay — doc-review sweep', () => { 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 afe284206..f0ac6f4a4 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 @@ -30,12 +30,17 @@ import { BillingService } from "../billing/billing.service"; import { DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_BULK_WAGON_TARE_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, } from "./booking-batch.constants"; import { + WagonTypeDimensions, + bookingGrossWeightTons, bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, + trainHardCaps, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; @@ -63,7 +68,14 @@ interface RouteDayGroup { day: string; } -type WagonLengths = { container: number; bulk: number }; +/** + * Per-freight-type wagon dimensions used to size a booking's capacity draw: + * its length on the train and the tare it adds to the locomotive's gross load. + */ +type WagonDims = { + container: { lengthMeters: number; tareWeightTons: number }; + bulk: { lengthMeters: number; tareWeightTons: number }; +}; export type BatchBoardBookingState = | "ALLOCATED" @@ -501,8 +513,8 @@ export class BookingBatchService implements OnModuleInit { } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); - const required = need ?? this.needFor(booking, wagonLengths); + const wagonDims = await this.loadWagonDims(); + const required = need ?? this.needFor(booking, wagonDims); let corridorMatched = false; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( @@ -511,7 +523,7 @@ export class BookingBatchService implements OnModuleInit { const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + 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 corridorMatched = true; @@ -549,8 +561,8 @@ export class BookingBatchService implements OnModuleInit { if (!partner || partner.status !== 'FULLY_EXECUTED') { return; } - const wagonLengths = await this.loadWagonLengths(); - const need = this.combinedNeed(booking, partner, wagonLengths); + const wagonDims = await this.loadWagonDims(); + const need = this.combinedNeed(booking, partner, wagonDims); const scheduleId = await this.pickExportSchedule(booking, need); await this.reserveOnExport([booking, partner], scheduleId); } @@ -620,7 +632,8 @@ export class BookingBatchService implements OnModuleInit { order: { scheduledDepartureDate: "ASC" }, }); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); + const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const board: BatchBoardSchedule[] = []; @@ -635,7 +648,7 @@ export class BookingBatchService implements OnModuleInit { const bookings = await this.bookingsRepository.findAllBySchedule(s.id); const items: BatchBoardBooking[] = bookings.map((b) => { - const need = this.needFor(b, wagonLengths); + const need = this.needFor(b, wagonDims); return { id: b.id, reference: b.reference ?? b.id.slice(0, 8), @@ -655,7 +668,7 @@ export class BookingBatchService implements OnModuleInit { }; }); - board.push(this.buildScheduleSummary(s, items)); + board.push(this.buildScheduleSummary(s, items, rules)); } return board; } @@ -678,7 +691,8 @@ export class BookingBatchService implements OnModuleInit { ); } - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); + const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -724,7 +738,7 @@ export class BookingBatchService implements OnModuleInit { } const items: BatchBoardBookingDetail[] = bookings.map((b) => { - const need = this.needFor(b, wagonLengths); + const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { id: b.id, @@ -871,7 +885,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -902,6 +916,13 @@ export class BookingBatchService implements OnModuleInit { return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } + /** + * Board capacity figures. `usedWeightTons` is GROSS (each item's weight already + * includes the tare of the wagons it occupies), so the ceiling it is measured + * against must be the same one the fill loop spends from: the locomotive floored + * by the global rule caps and widened by its overage tolerance. Reading the raw + * `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use. + */ private computeBoardCapacity( items: Array<{ state: BatchBoardBookingState; @@ -911,22 +932,40 @@ export class BookingBatchService implements OnModuleInit { }>, loco: Locomotive | null, maxWagons: number | null, + rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); + const caps = loco + ? trainHardCaps( + { + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + overageToleranceTons: Number(loco.overageToleranceTons) || 0, + overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, + }, + { + maxTrainWeightTons: rules?.maxTrainWeightTons + ? Number(rules.maxTrainWeightTons) + : undefined, + maxTrainLengthMeters: rules?.maxTrainLengthMeters + ? Number(rules.maxTrainLengthMeters) + : undefined, + }, + ) + : null; + const round2 = (value: number) => Math.round(value * 100) / 100; + return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), - allocatedLengthMeters: - Math.round( - allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, - ) / 100, - maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: - Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / - 100, - maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + allocatedLengthMeters: round2( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0), + ), + maxLengthMeters: caps ? caps.maxLengthMeters : null, + usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)), + maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, }; } @@ -934,6 +973,7 @@ export class BookingBatchService implements OnModuleInit { private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], + rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule { const loco = s.trainSet?.locomotive ?? null; @@ -966,7 +1006,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1036,10 +1076,10 @@ export class BookingBatchService implements OnModuleInit { } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonDims); if (budget.maxRemaining().wagons <= 0) { await this.setWindow(scheduleId, "FULL"); return 0; @@ -1064,8 +1104,8 @@ export class BookingBatchService implements OnModuleInit { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair - ? this.combinedNeed(booking, partner, wagonLengths) - : this.needFor(booking, wagonLengths); + ? this.combinedNeed(booking, partner, wagonDims) + : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. @@ -1084,7 +1124,7 @@ export class BookingBatchService implements OnModuleInit { need, leg, budget, - wagonLengths, + wagonDims, ); if (!freed) continue; // still doesn't fit even after preempt } else { @@ -1151,6 +1191,42 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { + const { scheduleIds } = await this.fillRouteDayInternal( + originYardId, + destinationYardId, + day, + ); + return scheduleIds; + } + + /** + * Route-day top-up for a single schedule: re-run the DAY pool over the whole + * corridor the schedule belongs to, and report how many commercial units got a + * fresh pay window. + * + * `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on + * `booking.train_schedule_id = :scheduleId`, but under day-level pooling a + * booking that has not been reserved yet has a NULL `train_schedule_id` — it is + * only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows + * and the waiting list never boarded after an expiry freed capacity; bookings + * trickled in one per window cycle instead. + */ + private async topUpFill(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return 0; + const { commercialReserved } = await this.fillRouteDayInternal( + schedule.originStationId, + schedule.destinationStationId, + eatDay(schedule.scheduledDepartureDate), + ); + return commercialReserved; + } + + private async fillRouteDayInternal( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise<{ scheduleIds: string[]; commercialReserved: number }> { // The day's fillable schedules on this exact corridor, earliest first. Fillable // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — // the batch must run while the customer window is closed. @@ -1168,23 +1244,36 @@ export class BookingBatchService implements OnModuleInit { }, ], }); - const scheduleIds = corridor + const onDay = corridor .filter( (s) => s.scheduledDepartureDate != null && - eatDay(s.scheduledDepartureDate) === day && - this.isFillable(s), + eatDay(s.scheduledDepartureDate) === day, ) .sort( (a, b) => a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), - ) - .map((s) => s.id); + ); - if (scheduleIds.length === 0) return []; + // A schedule flagged FULL is rejected by isFillable() before its budget is + // ever consulted. Re-derive that flag from live capacity first, so a train + // whose bookings all expired is not skipped forever with an empty consist. + for (const s of onDay) { + if (s.bookingWindowStatus === "FULL") { + await this.refreshWindowStatus(s.id); + const fresh = await this.trainSchedulesRepository.findById(s.id); + if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus; + } + } + + const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id); + + if (scheduleIds.length === 0) { + return { scheduleIds: [], commercialReserved: 0 }; + } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); // Live per-schedule corridor budget + arm flag, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; @@ -1200,10 +1289,10 @@ export class BookingBatchService implements OnModuleInit { } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, armed: false }); } - if (trains.length === 0) return []; + if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; // The day pool covers every booking whose leg lies somewhere on one of the // day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an @@ -1225,13 +1314,14 @@ export class BookingBatchService implements OnModuleInit { `poolSize=${pool.length} units=${units.length}`, ); let reservedThisPass = 0; + let commercialReserved = 0; for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair - ? this.combinedNeed(booking, partner, wagonLengths) - : this.needFor(booking, wagonLengths); + ? this.combinedNeed(booking, partner, wagonDims) + : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => @@ -1267,7 +1357,7 @@ export class BookingBatchService implements OnModuleInit { need, leg, t.budget, - wagonLengths, + wagonDims, ); if (freed) { target = t; @@ -1284,7 +1374,12 @@ export class BookingBatchService implements OnModuleInit { // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. const offered = await this.maybeOfferPartial(booking, isPair, trains, need); - if (offered) continue; + if (offered) { + // A partial offer opens a real commercial pay window, same as reserve(). + commercialReserved += 1; + reservedThisPass += 1; + continue; + } // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); if (partner) this.notifier.unplaced(partner, day); @@ -1305,6 +1400,7 @@ export class BookingBatchService implements OnModuleInit { await this.reserve(booking, target.id); if (partner) await this.reserve(partner, target.id); target.armed = true; + commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); reservedThisPass += 1; @@ -1326,7 +1422,7 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(t.id); } - return trains.map((t) => t.id); + return { scheduleIds: trains.map((t) => t.id), commercialReserved }; } /** @@ -1396,7 +1492,7 @@ export class BookingBatchService implements OnModuleInit { if (booking.consolidationPartnerId) return null; if (await this.splitService.findOpenOffer(booking.id)) return null; - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); const sized = await this.splitService.sizeOffer( booking, @@ -1408,11 +1504,16 @@ export class BookingBatchService implements OnModuleInit { const offeredNeed: Capacity = { wagons: sized.offeredWagons, - weightTons: sized.offeredWeightTons, - lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + weightTons: bookingGrossWeightTons( + sized.offeredWeightTons, + sized.offeredWagons, + this.tareFor(booking.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + booking.freightType, + sized.offeredWagons, + this.lengthsOf(wagonDims), + ), }; if (!this.fits(offeredNeed, budget)) return null; @@ -1510,7 +1611,7 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, ); - const topUpReserved = await this.fillSchedule(scheduleId); + const topUpReserved = await this.topUpFill(scheduleId); // A top-up opened a fresh pay window for waiting bookings — push the // schedule's PAYMENT phase out so the window tick's concludeCycle doesn't // fire before those customers' new deadlines and expire them prematurely. @@ -1526,7 +1627,7 @@ export class BookingBatchService implements OnModuleInit { async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); await this.settleReserved(scheduleId, true); - const topUpReserved = await this.fillSchedule(scheduleId); + const topUpReserved = await this.topUpFill(scheduleId); if (topUpReserved > 0) { await this.extendPaymentPhaseForTopUp(scheduleId); } @@ -1632,11 +1733,14 @@ export class BookingBatchService implements OnModuleInit { .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + // Capture the train before expire() detaches the booking from it — the + // top-up has to run against the schedule whose wagons were just freed. + const freedScheduleId = booking.trainScheduleId; await this.expire(booking); - if (booking.trainScheduleId) { - const topUpReserved = await this.fillSchedule(booking.trainScheduleId); + if (freedScheduleId) { + const topUpReserved = await this.topUpFill(freedScheduleId); if (topUpReserved > 0) { - await this.extendPaymentPhaseForTopUp(booking.trainScheduleId); + await this.extendPaymentPhaseForTopUp(freedScheduleId); } } } @@ -1659,10 +1763,10 @@ export class BookingBatchService implements OnModuleInit { const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) return null; const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); - return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) }; + const budget = await this.remainingBudget(schedule, limits, wagonDims); + return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } /** @@ -1827,6 +1931,7 @@ export class BookingBatchService implements OnModuleInit { * it failed to pay for — it's back in the day pool for staff to act on. */ private async expire(booking: Booking): Promise { + const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, status: "EXPIRED", @@ -1835,6 +1940,9 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // The wagons this reservation held are back — a schedule parked at FULL + // because of it must reopen, or it can never be filled again. + if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId); // An unpaid partial offer dies with the reservation — the booking stays whole. if (this.splitService) { await this.splitService.expireOpenOffer(booking.id); @@ -1939,7 +2047,7 @@ export class BookingBatchService implements OnModuleInit { need: Capacity, leg: CorridorLeg, budget: CorridorBudget, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Promise { if (budget.fits(need, leg)) return true; const reservedCommercial = ( @@ -1987,7 +2095,10 @@ export class BookingBatchService implements OnModuleInit { ); }); this.notifier.displaced(victim); - budget.add(this.needFor(victim, wagonLengths), victimLeg); + budget.add(this.needFor(victim, wagonDims), victimLeg); + // Displacing frees wagons the same way an expiry does — don't leave the + // schedule stuck at FULL. + await this.refreshWindowStatus(scheduleId); } return budget.fits(need, leg); } @@ -2041,7 +2152,7 @@ export class BookingBatchService implements OnModuleInit { private combinedNeed( primary: Booking, partner: Booking, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Capacity { const containers = (b: Booking): number => (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); @@ -2050,15 +2161,36 @@ export class BookingBatchService implements OnModuleInit { totalContainers > 0 ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) : this.wagonsFor(primary) + this.wagonsFor(partner); - const weightTons = + const cargoTons = Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); return { wagons: sharedWagons, - weightTons, - lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + // Consolidation saves tare as well as slots: the pair rides `sharedWagons` + // wagons, so it is charged `sharedWagons` tares, not one per booking. + weightTons: bookingGrossWeightTons( + cargoTons, + sharedWagons, + this.tareFor(primary.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + primary.freightType, + sharedWagons, + this.lengthsOf(wagonDims), + ), + }; + } + + /** Per-wagon tare for the wagon type this freight rides on. */ + private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number { + return freightType === 'BULK' + ? wagonDims.bulk.tareWeightTons + : wagonDims.container.tareWeightTons; + } + + private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } { + return { + container: wagonDims.container.lengthMeters, + bulk: wagonDims.bulk.lengthMeters, }; } @@ -2077,16 +2209,28 @@ export class BookingBatchService implements OnModuleInit { return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers); } - /** What one booking consumes along all three capacity axes. */ - private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity { + /** + * What one booking consumes along all three capacity axes. + * + * The weight axis is GROSS — cargo plus the tare of every wagon the booking + * occupies — because it is spent against the locomotive's pull limit, which + * governs the whole train and not just its payload. Charging cargo alone let a + * 37-wagon box-wagon train read 2590T when it really weighed 3522T. + */ + private needFor(booking: Booking, wagonDims: WagonDims): Capacity { const wagons = this.wagonsFor(booking); return { wagons, - weightTons: Number(booking.cargoTotalWeightVgm ?? 0), - lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + weightTons: bookingGrossWeightTons( + Number(booking.cargoTotalWeightVgm ?? 0), + wagons, + this.tareFor(booking.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + booking.freightType, + wagons, + this.lengthsOf(wagonDims), + ), }; } @@ -2098,7 +2242,11 @@ export class BookingBatchService implements OnModuleInit { ); } - /** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */ + /** + * Hard caps for a schedule's train: gross pull weight, train length, and the + * length-derived wagon slot count (never a fixed 53). Bookings spend against + * these via {@link needFor}, whose weight axis is gross. + */ private async capacityLimits( locomotive: Locomotive, rules: TrainSchedulingGlobalRules | null, @@ -2143,31 +2291,48 @@ export class BookingBatchService implements OnModuleInit { } } - private async loadWagonTypeDimensions(): Promise< - Array<{ lengthMeters: number; capacityTons: number }> - > { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: "NW5" }, { code: "CW3" }], - }); + /** + * Every active wagon type, so the slot count is derived from the shortest wagon + * the fleet can actually marshal rather than from an arbitrary two-code sample. + */ + private async loadWagonTypeDimensions(): Promise { + const types = await this.dataSource + .getRepository(WagonType) + .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ - { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, - { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + { + lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + capacityTons: 70, + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + { + lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, + capacityTons: 60, + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + }, ]; } - private async loadWagonLengths(): Promise { + /** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */ + private async loadWagonDims(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ where: [{ code: "NW5" }, { code: "CW3" }], }); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), ); + const nw5 = byCode.get("NW5"); + const cw3 = byCode.get("CW3"); return { - container: - byCode.get("NW5")?.lengthMeters ?? - DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: { + lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + bulk: { + lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS, + }, }; } @@ -2204,7 +2369,7 @@ export class BookingBatchService implements OnModuleInit { private async remainingBudget( schedule: TrainSchedule, limits: Capacity, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Promise { const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits); @@ -2216,7 +2381,7 @@ export class BookingBatchService implements OnModuleInit { ); for (const b of [...allocated, ...reserved]) { budget.subtract( - this.needFor(b, wagonLengths), + this.needFor(b, wagonDims), budget.legForYards(b.originYardId, b.destinationYardId), ); } @@ -2228,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit { * ≤ 0 means no leg can take another booking — the train-wide FULL signal. */ private async remainingWagons(schedule: TrainSchedule): Promise { - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const budget = await this.remainingBudget( schedule, { @@ -2236,7 +2401,7 @@ export class BookingBatchService implements OnModuleInit { weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }, - wagonLengths, + wagonDims, ); return budget.maxRemaining().wagons; } @@ -2269,6 +2434,35 @@ export class BookingBatchService implements OnModuleInit { return (await this.remainingWagons(schedule)) <= 0; } + /** + * Re-derive `bookingWindowStatus` from live capacity after wagons were freed + * (a reservation expired, a booking was displaced, a link was removed). + * + * FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before + * it ever looks at the budget, and the only writers of OPEN skip a FULL row. So + * a train that filled once and then lost every booking to expiry stayed FULL + * with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT + * forever while `concludeCycle` (which reads real capacity, not the flag) kept + * reopening it. Clearing FULL here is what lets the next batch actually run. + * + * Only the customer-facing OPEN phases may go back to OPEN; a schedule mid + * DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits. + */ + async refreshWindowStatus(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== "FULL") return; + if ((await this.remainingWagons(schedule)) <= 0) return; + + const customerWindowOpen = + schedule.windowPhase == null || schedule.windowPhase === "OPEN"; + await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED"); + this.logger.log( + `[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` + + `${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`, + ); + } + // ---- timer plumbing ------------------------------------------------------- /** Configured customer pay window in ms (global rules, with defaults). */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 0d9087419..f1978b762 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -328,6 +328,18 @@ export class BookingWindowService implements OnModuleInit { return; } + // Not full, so any FULL flag left over from a batch whose bookings later + // expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below + // refuses to reopen a FULL schedule, which is how a train with an empty + // consist used to cycle forever without ever being fillable again. Re-read + // the flag onto the in-memory row — advanceSchedule keeps looping on this + // same object, and PRE_WINDOW→OPEN reads it. + if (schedule.bookingWindowStatus === 'FULL') { + await this.bookingBatchService.refreshWindowStatus(schedule.id); + const fresh = await this.trainSchedulesRepository.findById(schedule.id); + if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus; + } + // Doc review + payment have already run, so the desk is ready to reopen NOW — // office hours decide whether that is this afternoon or tomorrow morning. Past // the last cycle before departure, nextCycleOpensAt returns null and we finish. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index 82fb7bd05..e1b4064bb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -1,64 +1,183 @@ import { + bookingGrossWeightTons, bookingTrainLengthMeters, + consistUsage, + consistViolations, deriveTrainCapacityFromLocomotive, + grossWagonWeightTons, minLocomotiveLimits, } from './train-capacity.util'; describe('train-capacity.util', () => { - const nw5 = { lengthMeters: 14, capacityTons: 70 }; + // Real EDR wagon specs. + const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 }; + const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 }; + const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 }; - it('derives wagon slots from locomotive length and weight, not a fixed 53', () => { - const shortLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, - [nw5], - ); - expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14 - expect(shortLoco.maxWagonSlots).not.toBe(53); - - const heavyLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, - [nw5], - ); - expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70 + const caps = (over = {}) => ({ + maxWeightTons: 3500, + maxLengthMeters: 760, + maxWagonSlots: 54, + ...over, }); - it('uses shortest wagon type when mixed types are present', () => { - const longBulk = { lengthMeters: 18, capacityTons: 80 }; - const mixed = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, - [nw5, longBulk], - ); - expect(mixed.maxWagonSlots).toBe( - Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)), - ); + const slots = (n: number, type: typeof nw5, cargoTons: number) => + Array.from({ length: n }, () => ({ + lengthMeters: type.lengthMeters, + tareWeightTons: type.tareWeightTons, + cargoTons, + })); + + describe('deriveTrainCapacityFromLocomotive', () => { + it('derives wagon slots from train length, not a fixed 53', () => { + const shortLoco = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, + [nw5], + ); + expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966) + expect(shortLoco.maxWagonSlots).not.toBe(53); + }); + + it('does not shrink slots by assuming every wagon rides at full payload', () => { + // A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a + // LENGTH figure — the cargo that decides weight does not exist yet. + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30 + expect(derived.maxWeightTons).toBe(2100); + }); + + it('admits the railway 53-wagon NW5 marshalling figure', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53); + }); + + it('uses the shortest wagon type when mixed types are present', () => { + const mixed = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5, pw2, gw2], + ); + expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62 + }); + + it('extends weight/length caps by the locomotive overage tolerance', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + [pw2], + ); + expect(derived.maxWeightTons).toBe(3590); + }); + + it('ignores overage tolerance when unset (strict cap)', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + + it('floors the locomotive by the global rule caps', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 5000, maxTrainLengthMeters: 900 }, + [nw5], + { maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 }, + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + }); + + describe('gross weight', () => { + it('counts the wagon as well as its cargo', () => { + expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2); + }); + + it('charges a booking one tare per wagon it occupies', () => { + // 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel. + expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2); + }); + + it('is cargo alone when the wagon type has no tare on record', () => { + expect(bookingGrossWeightTons(100, 3, 0)).toBe(100); + }); + }); + + describe('consistUsage', () => { + it('sums each wagon own length and tare rather than averaging a type', () => { + const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)]; + const usage = consistUsage(mixed, caps()); + + expect(usage.wagonCount).toBe(3); + expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066 + expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2 + expect(usage.usedCargoWeightTons).toBe(40); + expect(usage.usedGrossWeightTons).toBe(110); + expect(usage.remainingGrossWeightTons).toBe(3390); + expect(usage.remainingWagons).toBe(51); + }); + + it('reports an empty consist as fully available', () => { + const usage = consistUsage([], caps()); + expect(usage.usedGrossWeightTons).toBe(0); + expect(usage.remainingLengthMeters).toBe(760); + expect(usage.remainingWagons).toBe(54); + }); + }); + + describe('consistViolations', () => { + it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => { + // 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T. + const consist = slots(37, pw2, 70); + + expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([ + expect.stringContaining('3522.4T'), + ]); + expect( + consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })), + ).toEqual([]); + }); + + it('blocks a train the old cargo-only math would have waved through', () => { + // Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T. + const consist = slots(37, pw2, 70); + const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0); + + expect(cargoOnly).toBeLessThan(3500); + expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]); + }); + + it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => { + // 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T. + expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]); + }); + + it('flags an over-length consist', () => { + const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 })); + expect(violations).toEqual([expect.stringContaining('exceeds max train length')]); + }); + + it('flags an over-count consist', () => { + const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 })); + expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]); + }); + + it('reports every broken axis at once', () => { + expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3); + }); }); it('computes booking length by freight type', () => { - expect( - bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }), - ).toBe(28); + expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28); expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); }); - it('extends weight/length caps by the locomotive overage tolerance (fertilizer +90T example)', () => { - const pw2 = { lengthMeters: 17.066, capacityTons: 70 }; - const derived = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, - [pw2], - ); - expect(derived.maxWeightTons).toBe(3590); - }); - - it('ignores overage tolerance when unset (strict cap, no behavior change)', () => { - const derived = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, - [{ lengthMeters: 14, capacityTons: 70 }], - ); - expect(derived.maxWeightTons).toBe(3500); - expect(derived.maxLengthMeters).toBe(760); - }); - - it('takes the weakest locomotive tolerance across a multi-locomotive set', () => { + it('takes the weakest locomotive across a multi-locomotive set', () => { const limits = minLocomotiveLimits([ { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index fb52ec40c..79adf8d8f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -1,7 +1,40 @@ +/** + * Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable: + * + * count — how many wagons fit end to end on the longest allowed train + * length — Σ wagonType.lengthMeters over the real consist + * weight — Σ (wagonType.tareWeightTons + cargoTons) over the real consist + * + * The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it. + * The old code compared the locomotive's pull limit against cargo payload alone + * and so overbooked every train by roughly the tare fraction (~27% on PW2). + * + * The weight axis is also driven by ACTUAL booked cargo, never by an assumed + * full payload. That is what makes the real EDR numbers fall out: + * + * NW5 13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon + * marshalling figure is length-bound, and those trains never carry 53×70T. + * PW2 17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T, + * which clears 3500T only via the locomotive's overage tolerance. Weight + * binds first, hence "37 wagons per train". + * + * So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo + * exists. Weight is enforced against the consist as bookings are allocated. + */ + /** Physical dimensions used when deriving how many wagons a locomotive can pull. */ export type WagonTypeDimensions = { lengthMeters: number; capacityTons: number; + tareWeightTons: number; +}; + +/** One occupied wagon slot in a real consist. */ +export type ConsistSlot = { + lengthMeters: number; + tareWeightTons: number; + /** Actual cargo/container weight riding on this wagon, not its rated capacity. */ + cargoTons: number; }; export type LocomotiveLimits = { @@ -14,69 +47,166 @@ export type LocomotiveLimits = { }; export type DerivedTrainCapacity = { + /** Gross (tare + cargo) tons the train may weigh, tolerance included. */ maxWeightTons: number; maxLengthMeters: number; + /** Length-derived slot count. Weight is enforced separately against real cargo. */ maxWagonSlots: number; }; +/** What a consist currently uses, and what is left on each axis. */ +export type ConsistUsage = { + wagonCount: number; + usedLengthMeters: number; + /** Σ (tare + cargo). */ + usedGrossWeightTons: number; + usedTareWeightTons: number; + usedCargoWeightTons: number; + remainingLengthMeters: number; + remainingGrossWeightTons: number; + remainingWagons: number; +}; + +export const MAX_FALLBACK_WEIGHT = 3500; +export const MAX_FALLBACK_LENGTH = 760; + const DEFAULT_WAGON_LENGTH_M = 14; const DEFAULT_WAGON_CAPACITY_T = 70; +/** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */ +const DEFAULT_WAGON_TARE_T = 22.4; + +function num(value: unknown, fallback = 0): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ +export function grossWagonWeightTons(slot: Pick): number { + return num(slot.tareWeightTons) + num(slot.cargoTons); +} /** - * Derive train capacity from locomotive pull weight and train length. - * Wagon count is NOT a fixed 53 — it is the minimum of: - * - floor(maxLength / shortest wagon type length) - * - floor(maxWeight / lightest wagon type capacity) + * Hard caps for a train: the locomotive's own limits, floored by the global rule + * caps, then widened by the locomotive's overage tolerance. + */ +export function trainHardCaps( + locomotive: LocomotiveLimits, + ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, +): { maxWeightTons: number; maxLengthMeters: number } { + const overageTons = num(locomotive.overageToleranceTons); + const overageMeters = num(locomotive.overageToleranceMeters); + + const weight = + Math.min( + num(locomotive.maxPullWeightTons, Infinity) || Infinity, + ruleCaps?.maxTrainWeightTons ?? Infinity, + ) + overageTons; + const length = + Math.min( + num(locomotive.maxTrainLengthMeters, Infinity) || Infinity, + ruleCaps?.maxTrainLengthMeters ?? Infinity, + ) + overageMeters; + + return { + maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT, + maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH, + }; +} + +/** + * Derive the planning capacity of a train from its locomotive. + * + * `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within + * the train-length cap — the optimistic slot count, since a mixed consist of + * longer wagons will hit the length cap sooner. It is deliberately NOT reduced by + * weight: with no bookings yet there is no cargo, and assuming every wagon rides + * at full rated payload would report 37 NW5 slots where the railway marshals 53. + * Weight is enforced by {@link consistUsage} / {@link consistViolations} against + * the cargo actually allocated. */ export function deriveTrainCapacityFromLocomotive( locomotive: LocomotiveLimits, wagonTypes: WagonTypeDimensions[], ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, ): DerivedTrainCapacity { - const overageTons = Number(locomotive.overageToleranceTons) || 0; - const overageMeters = Number(locomotive.overageToleranceMeters) || 0; + const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps); - const maxWeightTons = - Math.min( - Number(locomotive.maxPullWeightTons) || Infinity, - ruleCaps?.maxTrainWeightTons ?? Infinity, - ) + overageTons; - const maxLengthMeters = - Math.min( - Number(locomotive.maxTrainLengthMeters) || Infinity, - ruleCaps?.maxTrainLengthMeters ?? Infinity, - ) + overageMeters; + const lengths = wagonTypes + .map((w) => num(w.lengthMeters)) + .filter((l) => l > 0); + const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M; - const types = - wagonTypes.length > 0 - ? wagonTypes - : [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }]; + const maxWagonSlots = + minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0; - const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M)); - const minCapacity = Math.min( - ...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T), - ); + return { maxWeightTons, maxLengthMeters, maxWagonSlots }; +} - const byLength = - minLength > 0 && Number.isFinite(maxLengthMeters) - ? Math.floor(maxLengthMeters / minLength) - : 0; - const byWeight = - minCapacity > 0 && Number.isFinite(maxWeightTons) - ? Math.floor(maxWeightTons / minCapacity) - : byLength; +/** + * What a real, mixed-type consist uses on all three axes, and what is left. + * Every wagon contributes its own length and its own tare — no averaging over a + * representative wagon type. + */ +export function consistUsage( + slots: ConsistSlot[], + caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, +): ConsistUsage { + let usedLengthMeters = 0; + let usedTareWeightTons = 0; + let usedCargoWeightTons = 0; - const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight)); + for (const slot of slots) { + usedLengthMeters += num(slot.lengthMeters); + usedTareWeightTons += num(slot.tareWeightTons); + usedCargoWeightTons += num(slot.cargoTons); + } + + const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons; return { - maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT, - maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH, - maxWagonSlots, + wagonCount: slots.length, + usedLengthMeters: round3(usedLengthMeters), + usedGrossWeightTons: round3(usedGrossWeightTons), + usedTareWeightTons: round3(usedTareWeightTons), + usedCargoWeightTons: round3(usedCargoWeightTons), + remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters), + remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons), + remainingWagons: caps.maxWagonSlots - slots.length, }; } -export const MAX_FALLBACK_WEIGHT = 3500; -export const MAX_FALLBACK_LENGTH = 760; +/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */ +export function consistViolations( + slots: ConsistSlot[], + caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, +): string[] { + const usage = consistUsage(slots, caps); + const violations: string[] = []; + + if (usage.usedGrossWeightTons > caps.maxWeightTons) { + violations.push( + `Total train gross weight ${usage.usedGrossWeightTons}T ` + + `(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` + + `exceeds max pull weight ${round3(caps.maxWeightTons)}T`, + ); + } + if (usage.usedLengthMeters > caps.maxLengthMeters) { + violations.push( + `Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`, + ); + } + if (usage.wagonCount > caps.maxWagonSlots) { + violations.push( + `Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`, + ); + } + + return violations; +} + +function round3(value: number): number { + return Number.isFinite(value) ? Number(value.toFixed(3)) : value; +} /** * Effective pull limits for a train set with multiple locomotives: the weakest @@ -92,18 +222,14 @@ export function minLocomotiveLimits( if (!locomotives.length) return null; return { maxPullWeightTons: Math.min( - ...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity), + ...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity), ), maxTrainLengthMeters: Math.min( - ...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity), + ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), // Weakest locomotive's tolerance governs the set, same as its caps. - overageToleranceTons: Math.min( - ...locomotives.map((l) => Number(l.overageToleranceTons) || 0), - ), - overageToleranceMeters: Math.min( - ...locomotives.map((l) => Number(l.overageToleranceMeters) || 0), - ), + overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))), + overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))), }; } @@ -117,12 +243,27 @@ export function bookingTrainLengthMeters( return wagonCount * perWagon; } +/** + * Gross weight a booking adds to its train: its cargo plus the tare of every + * wagon it occupies. A booking is never weightless just because it is light — + * the empty wagons still have to be pulled. + */ +export function bookingGrossWeightTons( + cargoTons: number, + wagonCount: number, + tarePerWagonTons: number, +): number { + return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons)); +} + export function wagonTypeDimensionsFromEntity(wt: { lengthMeters?: number | string | null; capacityTons?: number | string | null; + tareWeightTons?: number | string | null; }): WagonTypeDimensions { return { - lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, - capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, + capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T, }; } 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 79b009d79..0c366ed05 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 @@ -102,10 +102,13 @@ import { deriveTrainCapacityFromLocomotive, minLocomotiveLimits, wagonTypeDimensionsFromEntity, + WagonTypeDimensions, } from './train-capacity.util'; import { DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_BULK_WAGON_TARE_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; import { computeExportWindowTimes, @@ -3125,16 +3128,27 @@ export class TrainSchedulingService { }; } - private async loadSchedulingWagonTypeDimensions(): Promise< - Array<{ lengthMeters: number; capacityTons: number }> - > { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], - }); + /** + * Every active wagon type: the slot count derives from the shortest wagon the + * fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at + * 12.228m) and under-report how many wagons the train length allows. + */ + private async loadSchedulingWagonTypeDimensions(): Promise { + const types = await this.dataSource + .getRepository(WagonType) + .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ - { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, - { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + { + lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + capacityTons: 70, + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + { + lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, + capacityTons: 60, + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + }, ]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 0221144b3..30a46a099 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -2,6 +2,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { consistViolations } from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -35,6 +36,9 @@ export type WagonPlanSlot = { wagonTypeCode: string; capacityTons: number; lengthMeters: number; + /** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */ + tareWeightTons: number; + /** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */ assignedWeightTons: number; allocations: WagonAllocationRecord[]; slotLoadType?: SlotLoadType; @@ -78,6 +82,14 @@ export function roundTons(value: number | string | null | undefined): number { return Number(numericValue.toFixed(3)); } +/** + * Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill; + * a missing tare must read as 0 rather than silently inventing dead weight. + */ +export function tareTonsOf(wagonType: Pick): number { + return roundTons(wagonType.tareWeightTons ?? 0); +} + /** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; @@ -146,6 +158,7 @@ export function buildContainerWagonPlan( wagonTypeCode: wagonType.code, capacityTons: Number(wagonType.capacityTons), lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], })); @@ -175,6 +188,7 @@ export function buildBulkWagonPlan( wagonTypeCode: wagonType.code, capacityTons: capacity, lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], })); @@ -214,6 +228,7 @@ export function buildMixedWagonPlan( wagonTypeCode: containerWagonType.code, capacityTons: Number(containerWagonType.capacityTons), lengthMeters: Number(containerWagonType.lengthMeters), + tareWeightTons: tareTonsOf(containerWagonType), assignedWeightTons: 0, allocations: [], slotLoadType: 'CONTAINER', @@ -443,47 +458,46 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string return violations; } +/** + * Check a consist against its train's three limits. Weight is GROSS — every slot + * contributes its own tare plus the cargo assigned to it — because the locomotive + * pull limit governs what it drags, not what was sold. Length and tare are summed + * per slot, so a mixed consist is measured as it actually stands rather than + * through one representative wagon type. + * + * `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain` + * is absent; slot dimensions always win over it. + */ export function validateTrainLimits( wagonPlan: WagonPlanSlot[], wagonType: Pick, limits?: TrainLimitConfig, ): string[] { - const violations: string[] = []; const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const wagonLength = Number(wagonType.lengthMeters) || 14; - const maxWagonsPerTrain = - limits?.maxWagonsPerTrain ?? - Math.floor(maxLengthMeters / wagonLength); + const maxWagonSlots = + limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength); - const totalWeightTons = roundTons( - wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0), + const violations = consistViolations( + wagonPlan.map((slot) => ({ + lengthMeters: Number(slot.lengthMeters), + tareWeightTons: Number(slot.tareWeightTons ?? 0), + cargoTons: Number(slot.assignedWeightTons), + })), + { maxWeightTons, maxLengthMeters, maxWagonSlots }, ); - const totalLengthMeters = roundTons( - wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), - ); - - if (totalWeightTons > maxWeightTons) { - violations.push( - `Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`, - ); - } - if (totalLengthMeters > maxLengthMeters) { - violations.push( - `Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`, - ); - } - if (wagonPlan.length > maxWagonsPerTrain) { - violations.push( - `Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`, - ); - } violations.push(...validateBulkWagonSlotWeights(wagonPlan)); return violations; } +/** + * Mixed consist: the wagon-count fallback uses the shortest type present, since + * that is the most wagons that could ever fit. Weight and length still come from + * the slots themselves. + */ export function validateMixedTrainLimits( wagonPlan: WagonPlanSlot[], wagonTypes: WagonType[], diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts index 33fa0bcb0..49bbd1f5f 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -56,6 +56,17 @@ export class CreateWagonTypeDto { @Min(0.001) lengthMeters!: number; + @ApiProperty({ + description: + 'Empty (unladen) wagon weight in metric tons. Required: the locomotive pull ' + + 'limit applies to gross weight (tare + cargo), so capacity cannot be computed without it.', + example: 22.4, + }) + @Transform(toNumber) + @IsNumber() + @Min(0.001) + tareWeightTons!: number; + @ApiPropertyOptional({ description: 'Supported load types, e.g. CONTAINER,BULK', type: [String], diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts index e418460c4..b3e410f4b 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -28,8 +28,9 @@ export class WagonType extends BaseEntity { @Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true }) equatedLengthM?: number | null; - @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) - tareWeightTons?: number | null; + /** Empty wagon weight. Required: the locomotive's pull limit is a gross limit. */ + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + tareWeightTons!: number; @Column({ name: 'supports_container', type: 'boolean', default: false }) supportsContainer!: boolean; diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index 79cecf659..a61880f06 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -80,6 +80,7 @@ export class WagonTypesService { name: dto.name.trim(), capacityTons: dto.capacityTons, lengthMeters: dto.lengthMeters, + tareWeightTons: dto.tareWeightTons ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? [], isActive: dto.isActive ?? true, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 1f43f322c..2f8fa67fd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -536,6 +536,7 @@ export function WagonTypesCrudPage() { name: '', capacityTons: 0, lengthMeters: 0, + tareWeightTons: '', supportedLoadTypes: '', isActive: true, }); @@ -586,6 +587,7 @@ export function WagonTypesCrudPage() { name: '', capacityTons: 0, lengthMeters: 0, + tareWeightTons: '', supportedLoadTypes: '', isActive: true, }); @@ -599,6 +601,7 @@ export function WagonTypesCrudPage() { name: type.name ?? '', capacityTons: type.capacityTons ?? 0, lengthMeters: type.lengthMeters ?? 0, + tareWeightTons: type.tareWeightTons ?? '', supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '', isActive: type.isActive, }); @@ -607,10 +610,17 @@ export function WagonTypesCrudPage() { const validateWagonType = () => { const errors: Record = {}; + // normalizePayload strips empty strings, so a blank numeric field would be + // dropped from the payload rather than rejected. Each must be a positive + // number here — the API's @Min(0.001) agrees. + const positive = (value: FormValue) => Number.isFinite(Number(value)) && Number(value) > 0; + if (!String(form.code ?? '').trim()) errors.code = 'Code is required'; if (!String(form.name ?? '').trim()) errors.name = 'Name is required'; - if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number'; - if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number'; + if (!positive(form.capacityTons)) errors.capacityTons = 'Capacity must be greater than 0'; + if (!positive(form.lengthMeters)) errors.lengthMeters = 'Length must be greater than 0'; + if (!positive(form.tareWeightTons)) + errors.tareWeightTons = 'Tare weight is required and must be greater than 0'; return errors; }; @@ -707,6 +717,7 @@ export function WagonTypesCrudPage() { Length (m) + Tare weight (tons) Load types Status Actions @@ -719,6 +730,7 @@ export function WagonTypesCrudPage() { {type.name} {type.capacityTons} {type.lengthMeters} + {type.tareWeightTons ?? '-'} {type.supportedLoadTypes?.join(', ') || '-'} @@ -747,7 +759,7 @@ export function WagonTypesCrudPage() { ))} {!query.isLoading && filtered.length === 0 ? ( - + No wagon types found. @@ -756,7 +768,7 @@ export function WagonTypesCrudPage() { ) : null} {query.isLoading ? ( - + Loading... @@ -811,6 +823,15 @@ export function WagonTypesCrudPage() { error={fieldErrors.lengthMeters} onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))} /> + setForm((current) => ({ ...current, tareWeightTons: value }))} + /> statusBadge(locomotive.status) }, { key: 'maxPullWeightTons', label: 'Max pull (tons)' }, { key: 'maxTrainLengthMeters', label: 'Max length (m)' }, + { + key: 'overageToleranceTons', + label: 'Weight tolerance (t)', + render: (locomotive) => locomotive.overageToleranceTons ?? '-', + }, + { + key: 'overageToleranceMeters', + label: 'Length tolerance (m)', + render: (locomotive) => locomotive.overageToleranceMeters ?? '-', + }, ]} fields={[ { key: 'code', label: 'Code', required: true }, @@ -1137,6 +1168,11 @@ export function LocomotivesCrudPage() { }, { key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true }, { key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true }, + // Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2 + // wagons gross 3,522.4T against a 3,500T pull limit and only board because + // of the weight tolerance. + { key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' }, + { key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' }, { key: 'powerKw', label: 'Power (kW)', type: 'number' }, { key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' }, { key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' }, @@ -1148,6 +1184,8 @@ export function LocomotivesCrudPage() { status: 'AVAILABLE', maxPullWeightTons: 0, maxTrainLengthMeters: 760, + overageToleranceTons: '', + overageToleranceMeters: '', powerKw: '', tractionForceKn: '', maxSpeedKmh: '', diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 3ac7f2458..9e6ef7169 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -158,6 +158,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, { id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" }, { id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" }, + { id: "overageToleranceTons", header: "Weight tolerance (t)", accessorKey: "overageToleranceTons", format: "number" }, + { id: "overageToleranceMeters", header: "Length tolerance (m)", accessorKey: "overageToleranceMeters", format: "number" }, ], // Code is auto-generated server-side (LOCO-NNN) — omitted from the form. formFields: [ @@ -167,6 +169,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, { name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true }, { name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true }, + // Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2 + // wagons gross 3,522.4T against a 3,500T pull limit and only board because + // of the weight tolerance. + { name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" }, + { name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" }, { name: "powerKw", label: "Power (kW)", type: "number" }, { name: "tractionForceKn", label: "Traction force (kN)", type: "number" }, { name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" }, @@ -178,6 +185,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ currentYardId: "", maxPullWeightTons: 2500, maxTrainLengthMeters: 760, + overageToleranceTons: "", + overageToleranceMeters: "", powerKw: "", tractionForceKn: "", maxSpeedKmh: "", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index b02bf7e22..1b7e490e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -175,6 +175,7 @@ function CapacityChip({ ); } +/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */ function weightPctOf(s: BatchBoardSchedule) { return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0 ? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100 @@ -185,6 +186,11 @@ function lengthPctOf(s: BatchBoardSchedule) { ? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100 : null; } +function wagonPctOf(s: BatchBoardSchedule) { + return s.capacity.maxWagons && s.capacity.maxWagons > 0 + ? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100 + : null; +} function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const navigate = useNavigate(); @@ -192,6 +198,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const lengthPct = lengthPctOf(schedule); const weightPct = weightPctOf(schedule); + const wagonPct = wagonPctOf(schedule); const totalBookings = totalBookingCount(counts); @@ -275,7 +282,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { ) : null} - {/* capacity: weight + length rings + wagons (numbers preserved) */} + {/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */} ) : null} - - - - - - {capacity.allocatedWagons} - - - Wagons - - - allocated - - + {wagonPct != null ? ( + + ) : ( + + + + + + {capacity.allocatedWagons} + + + Wagons + + + allocated + + + )} {lengthPct != null ? ( ( - - - - - - - {row.original.capacity.allocatedWagons} - - - wgn - + cell: ({ row }) => { + const { allocatedWagons, maxWagons } = row.original.capacity; + const wagonPct = wagonPctOf(row.original); + return ( + + + + {wagonPct != null ? ( + + ) : ( + + + + {allocatedWagons} + + + wgn + + + )} - - ), + ); + }, }, { id: "bookings", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 6daf79221..3684dd1e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -892,7 +892,9 @@ export default function BatchScheduleDetailPage() { items={[ { label: "Allocated wagons", - value: data.capacity.allocatedWagons, + value: data.capacity.maxWagons + ? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}` + : data.capacity.allocatedWagons, hint: "on this train", icon: Boxes, }, @@ -904,10 +906,11 @@ export default function BatchScheduleDetailPage() { icon: Ruler, }, { - label: "Weight", + label: "Gross weight", value: data.capacity.maxWeightTons ? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}` : fmtTons(data.capacity.usedWeightTons), + hint: "wagon tare + cargo", icon: Weight, }, { diff --git a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts index f904eec5c..3c96b3cfb 100644 --- a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts @@ -27,6 +27,10 @@ export interface Locomotive { currentYard?: { id: string; label?: string; code?: string } | null; maxPullWeightTons: number; maxTrainLengthMeters: number; + /** Tons a train may exceed maxPullWeightTons by before scheduling blocks it. */ + overageToleranceTons?: number | null; + /** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */ + overageToleranceMeters?: number | null; powerKw?: number | null; tractionForceKn?: number | null; maxSpeedKmh?: number | null; diff --git a/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts index 699c0b2fc..bb4eead1f 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts @@ -8,6 +8,8 @@ export interface WagonType { name: string; capacityTons: number; lengthMeters: number; + /** Empty wagon weight; required, since pull limits apply to tare + cargo. */ + tareWeightTons: number; supportedLoadTypes: string[]; isActive: boolean; } diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index ab14a18da..c09c067d8 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -280,10 +280,13 @@ export interface BatchBoardSchedule { capacity: { allocatedWagons: number; allocatedLengthMeters: number; + /** Train-length cap: locomotive floored by global rules, plus overage tolerance. */ maxLengthMeters: number | null; + /** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */ usedWeightTons: number; + /** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */ maxWeightTons: number | null; - /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + /** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */ maxWagons: number | null; }; counts: { From f1452ba67e98100d5eaa53e6224447493b8d4839 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 9 Jul 2026 09:38:34 +0300 Subject: [PATCH 07/31] feat: ( forget password ) add phone number --- .../backoffice/src/app/login/page.tsx | 38 +++++++++---------- .../backoffice/src/lib/api/auth.ts | 6 ++- .../portal/src/app/forgot-password/page.tsx | 22 +++++------ .../portal/src/lib/api/auth.ts | 6 ++- 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index f104db3da..c0d84cae8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -29,12 +29,12 @@ export default function LoginPage() { const [emailFocused, setEmailFocused] = useState(false); const [passwordFocused, setPasswordFocused] = useState(false); - const [view, setView] = useState<'login' | 'forgot'>('login'); - const [forgotEmail, setForgotEmail] = useState(''); - const [forgotLoading, setForgotLoading] = useState(false); - const [forgotError, setForgotError] = useState(''); - const [forgotSent, setForgotSent] = useState(false); - const [forgotFocused, setForgotFocused] = useState(false); + const [view, setView] = useState<'login' | 'forgot'>('login'); + const [forgotIdentifier, setForgotIdentifier] = useState(''); + const [forgotLoading, setForgotLoading] = useState(false); + const [forgotError, setForgotError] = useState(''); + const [forgotSent, setForgotSent] = useState(false); + const [forgotFocused, setForgotFocused] = useState(false); const router = useRouter(); const { login } = useAuthStore(); @@ -66,13 +66,13 @@ export default function LoginPage() { setForgotLoading(true); setForgotError(''); try { - await iamAuthApi.forgotPassword(forgotEmail); + await iamAuthApi.forgotPassword(forgotIdentifier.trim()); setForgotSent(true); } catch (err: any) { const msg = err.response?.data?.message || err.message || ''; setForgotError( msg === 'user_not_found' - ? 'No account found with that email address.' + ? 'No account found with that email or phone number.' : msg || 'Failed to send the reset link. Please try again.' ); } finally { @@ -209,7 +209,7 @@ export default function LoginPage() {
@@ -295,10 +295,10 @@ export default function LoginPage() { )}
- {/* Email field */} + {/* Email or phone field */}
{ setForgotEmail(e.target.value); setForgotError(''); }} + type="text" + value={forgotIdentifier} + onChange={(e) => { setForgotIdentifier(e.target.value); setForgotError(''); }} onFocus={() => setForgotFocused(true)} onBlur={() => setForgotFocused(false)} className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none" - placeholder="name@edr.com" + placeholder="name@edr.com or +251..." required - autoComplete="email" + autoComplete="username" />
@@ -322,9 +322,9 @@ export default function LoginPage() { {/* Submit */}
diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts index 14e9a16a1..b37361238 100644 --- a/apps/edr-passenger-web/portal/src/lib/api/auth.ts +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -8,8 +8,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; // current password is wrong on change-password, and OTP failures must surface // as inline errors, not a logout. export const iamAuthApi = { - forgotPassword: (email: string) => - axios.post(`${API_URL}/v1/auth/forgot-password`, { email }), + // `identifier` can be an email address or a phone number — the IAM accepts + // either in the `email` field of the forgot-password body. + forgotPassword: (identifier: string) => + axios.post(`${API_URL}/v1/auth/forgot-password`, { email: identifier }), // Re-sends the registration verification code for a still-pending account. resendRegistrationCode: (data: { email: string; phoneNumber: string }) => From aa656eed7ed3934b8e31317f5e0c3462b197fac9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 06:40:45 +0000 Subject: [PATCH 08/31] fix: profile id and licence to registration --- .../companies/company-profile.repository.ts | 22 ++++- .../entities/company-profile.entity.ts | 2 +- .../src/seed/file-upload-settings.seeder.ts | 83 ++++++++++--------- .../pages/bookings/resubmit/resubmitDocs.ts | 2 +- .../src/pages/settings/NationalitySelect.tsx | 2 +- 5 files changed, 68 insertions(+), 43 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index 15aec5ac6..9bf14cd61 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, InternalServerErrorException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; @@ -20,6 +20,11 @@ const PREFIX_MAP: Record = { [ProfileType.transporter]: "TR", }; +const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** Numbers per series letter: A00001..A99999, then B00001. */ +const SERIES_SIZE = 99_999; + @Injectable() export class CompanyProfileRepository extends BaseRepository { constructor( @@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository { const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); - const nextId = result[0].next_id as number; + const nextId = Number(result[0].next_id); + const offset = nextId - 1; + const seriesIndex = Math.floor(offset / SERIES_SIZE); + + if (seriesIndex >= SERIES_LETTERS.length) { + throw new InternalServerErrorException( + `Company profile reference series exhausted for type "${type}"`, + ); + } + + const letter = SERIES_LETTERS[seriesIndex]; + const number = (offset % SERIES_SIZE) + 1; const prefix = PREFIX_MAP[type]; - return `${prefix}-${String(nextId).padStart(5, "0")}`; + return `${prefix}-${letter}${String(number).padStart(5, "0")}`; } async findByCompanyId(companyId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 72696766f..2266b0c65 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity { type!: ProfileType; /** - * Official profile reference (e.g. "EX-00001"). Minted only when the profile + * Official profile reference (e.g. "EX-A00001"). Minted only when the profile * is approved (status → Active); pending/unapproved profiles carry NULL. * The unique index tolerates this because Postgres treats NULLs as distinct. * API responses surface it as "" when absent — see ResponseCompanyProfileDto. diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 1aef33801..99dae5974 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -33,8 +33,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ }, { fileKey: "commercial_license", - fileLabel: "Commercial License", - helpText: "Verified against the government trade system during registration.", + fileLabel: "Commercial Registration", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ { fileKey: "business_license", fileLabel: "Business License / Trade License", - helpText: "Verified against the government trade system during registration.", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ const CONTRACT_INTAKE_ENTITY = "contract_intake"; const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ - clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, { - required: false, - }), + clearanceField( + "commercial_framework", + "Commercial Framework / Agreement", + 1, + { + required: false, + }, + ), clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { required: false, }), @@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { await this.dataSource.transaction(async (manager) => { @@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder { const allSettings: Array< OnboardingDocumentSetting & { description: string } > = [ - ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ - ...s, - description: COMPANY_ONBOARDING_DESCRIPTION, - })), - ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: CLEARANCE_DESCRIPTION, - })), - ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", - })), - ...SELF_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", - })), - ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ - ...s, - description: - "Commercial/framework documents attached at contract submission.", - })), - ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: - "Documents uploaded against a driver profile (license, ID, contracts, etc.).", - })), - ]; + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", + })), + ...SELF_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", + })), + ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ + ...s, + description: + "Commercial/framework documents attached at contract submission.", + })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), + ]; for (const documentSetting of allSettings) { await settingRepository.upsert( @@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder { }); if (!setting) { - throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`); + throw new Error( + `file_upload_setting_seed_failed:${documentSetting.code}`, + ); } await fieldRepository.delete({ settingId: setting.id }); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts index d8b660413..0a2f1a30f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts @@ -10,7 +10,7 @@ const LABEL_BY_CODE = new Map([ ...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const), // Company onboarding document codes (see file-upload-settings seeder). ["tin_certificate", "TIN Certificate"], - ["commercial_license", "Commercial License"], + ["commercial_license", "Commercial Registration"], ["business_license", "Business License / Trade License"], ["investment_license", "Investment License"], ["national_id", "National ID"], diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx index 5b779847a..2115ec083 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -26,7 +26,7 @@ export default function NationalitySelect({ } selected={value === "ethiopian"} onClick={() => onChange("ethiopian")} From 66edeb84bf1c0f14bbbcee69894618767dedb302 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 07:02:02 +0000 Subject: [PATCH 09/31] style: update the chat widget --- .../src/features/support/SupportWidget.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx b/apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx index 2b46a97b6..96ecc7276 100644 --- a/apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx +++ b/apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Headset, MessageCircle } from 'lucide-react'; +import { Headset } from 'lucide-react'; import { useState } from 'react'; import { SupportPanel } from './SupportPanel'; @@ -8,6 +8,7 @@ import { useUnreadCount } from './useSupport'; import { useSupportSocket } from './useSupportSocket'; const GREEN = 'rgb(20 113 76)'; +const GRADIENT = `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`; /** * Floating support launcher for the portal. One device-scoped thread — opens @@ -28,10 +29,27 @@ export function SupportWidget() { + ) : null} + refetch()} + loading={isFetching} + aria-label="Refresh" + > + + +
} /> @@ -528,7 +595,14 @@ export default function ContractClearanceListPage() {
- {view === "table" ? ( + {queueTab === "shipments" ? ( + navigate(`/dashboard/clearance/${id}`)} + /> + ) : view === "table" ? ( columns={columns} @@ -567,6 +641,143 @@ export default function ContractClearanceListPage() { ); } +interface ShipmentBookingRow { + id: string; + reference: string; + customerLabel: string; + originLabel: string; + destinationLabel: string; + tradeDirection: string; + freightType: string; + status: string; +} + +const prettyStatus = (s: string) => + s + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +const shipmentStatusColor = (s: string) => { + if (s === "AWAITING_DOCUMENTS") return "yellow"; + if (s === "DOCUMENTS_UNDER_REVIEW") return "blue"; + if (s === "CLEARANCE_READY") return "edr-green"; + return "gray"; +}; + +/** GENERAL-contract shipment bookings currently in per-booking clearance. */ +function ShipmentBookingsTable({ + rows, + loading, + error, + onOpen, +}: { + rows: ShipmentBookingRow[]; + loading: boolean; + error: boolean; + onOpen: (id: string) => void; +}) { + const columns = useMemo[]>( + () => [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => ( +
+
+ +
+
+

+ {row.original.reference} +

+

+ + {row.original.customerLabel} +

+
+
+ ), + }, + { + id: "route", + header: () => Route, + cell: ({ row }) => ( + + + {row.original.originLabel} + + + + {row.original.destinationLabel} + + + ), + }, + { + id: "kind", + header: () => Type, + cell: ({ row }) => ( + + + {prettyStatus(row.original.tradeDirection)} + + + {prettyStatus(row.original.freightType)} + + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( + + {prettyStatus(row.original.status)} + + ), + }, + { + id: "chevron", + header: "", + cell: () => ( + + + + ), + }, + ], + [], + ); + + if (!loading && !error && rows.length === 0) { + return ( + + + + + No shipment bookings in clearance. + + ); + } + + return ( + + + columns={columns} + data={rows} + status={loading ? "loading" : error ? "error" : "success"} + onRowClick={(row) => onOpen(row.id)} + containerClassName="border-0 shadow-none bg-transparent" + /> + + ); +} + function ClearanceCardGrid({ rows, loading, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 0b7456851..733c882bb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,62 +1,99 @@ import { useNavigate } from "react-router-dom"; import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; -import { ChevronRight, Ship } from "lucide-react"; +import { ChevronRight, PackageCheck, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; +import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings"; export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); + const { data: bookingQueue, isLoading: bookingsLoading } = + useBookingDjClearanceQueue(); const contractItems = contractQueue?.items ?? []; + const bookingItems = bookingQueue ?? []; return ( - {contractsLoading ? ( + {contractsLoading || bookingsLoading ? ( ) : ( - {contractItems.length === 0 ? ( + {contractItems.length === 0 && bookingItems.length === 0 ? ( - No Djibouti customs contracts yet. + No Djibouti customs work yet. ) : ( - contractItems.map((c) => ( - navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} - > - - - -
- {c.reference} - - {c.tradeDirection} · {c.status} - -
+ <> + {contractItems.map((c) => ( + navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} + > + + + +
+ {c.reference} + + {c.tradeDirection} · {c.status} + +
+
+ + + Contract + + +
- - - Contract - - +
+ ))} + {bookingItems.map((b) => ( + navigate(`/dashboard/clearance/${b.id}`)} + > + + + +
+ {b.reference} + + {b.tradeDirection} · {b.status} + +
+
+ + + Shipment + + +
-
-
- )) + + ))} + )}
)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 2bb3561cb..784fa6bf5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -241,8 +241,12 @@ export default function ContractDetailPage() { }); // Intercity contracts are never window-gated: the shipment rides a passing // import/export train that staff assign later, so booking is always open. + // GENERAL contracts are also not gated at creation — the booking enters the + // per-booking clearance gate first and picks its shipment day at proceed time. const bookingWindowOpen = - contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows); + contract?.tradeDirection === "DOMESTIC" || + contract?.contractKind === "GENERAL" || + hasOpenWindow(bookingWindows); // Draw-down capacity per cargo line (GENERAL contracts only). The backend // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index c36a74255..9410fbc7b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -139,8 +139,14 @@ export default function NewShipmentPage() { // open, show the same closed-state notice as the contract page instead of the // form. Still allowed the moment any window isOpenNow. Intercity contracts // are never window-gated — the shipment rides a passing train that staff - // pick at finalize time, so booking is always open. - if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) { + // pick at finalize time, so booking is always open. GENERAL contracts are not + // gated at creation either: the booking enters per-booking clearance first + // and picks its shipment day at proceed time. + if ( + contract.tradeDirection !== "DOMESTIC" && + contract.contractKind !== "GENERAL" && + !hasOpenWindow(bookingWindows) + ) { return ( Date: Thu, 9 Jul 2026 10:32:19 +0300 Subject: [PATCH 12/31] Update webhooks.controller.ts --- .../modules/webhooks/webhooks.controller.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index 66232dfcc..f1e4fa6b0 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -51,8 +51,22 @@ export class WebhooksController { @ApiOperation({ summary: "Telebirr payment notification callback (Ethiopia)", }) - async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) { - this.logger.log("Telebirr webhook called"); + async receiveTelebirr( + @Body() payload: TelebirrWebhookPayload, + @Headers() headers: Record, + @Req() req: { method?: string; rawBody?: Buffer }, + ) { + this.logger.log( + `Telebirr webhook hit: method=${req.method ?? "n/a"} ` + + `merchOrderId=${payload?.merch_order_id ?? "n/a"} ` + + `paymentOrderId=${payload?.payment_order_id ?? "n/a"} ` + + `tradeStatus=${payload?.trade_status ?? "n/a"}`, + ); + this.logger.log(`Telebirr webhook headers: ${JSON.stringify(headers)}`); + this.logger.log(`Telebirr webhook payload: ${JSON.stringify(payload)}`); + this.logger.log( + `Telebirr webhook raw body: ${req.rawBody?.toString("utf8") ?? "(none)"}`, + ); try { await this.telebirr.handle(payload); } catch (err) { From 61ec67cc2e5bb1c5fde31252cc52c2edb559bdd1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 07:42:02 +0000 Subject: [PATCH 13/31] Refactor wagon specifications to rely on wagon type; remove tare weight and max payload from wagon entity and related components --- .../2080000000000-DropWagonSpecColumns.ts | 53 +++++++++++++++++++ .../train-scheduling.service.ts | 2 +- .../modules/wagons/dto/create-wagon.dto.ts | 11 ++-- .../modules/wagons/entities/wagon.entity.ts | 13 +++-- .../src/modules/wagons/wagons.service.ts | 15 ++++-- .../scripts/seed-gate-pass-train-scenarios.ts | 2 - .../seed-negad-indode-arrived-train.ts | 2 - .../src/seed/demo-bookings.seeder.ts | 2 - .../src/seed/demo-freight-data.seeder.ts | 4 -- .../seed/marshalling-demo-trains.seeder.ts | 7 --- .../src/pages/fleet/FleetCrudPages.tsx | 25 +++++---- .../src/pages/fleet/config/resources.ts | 7 +-- .../backoffice/src/services/wagon.service.ts | 6 ++- 13 files changed, 99 insertions(+), 50 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts diff --git a/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts b/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts new file mode 100644 index 000000000..0fe3569f4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagon spec belongs to the wagon TYPE, not to each physical wagon. + * + * `wagons.tare_weight` and `wagons.max_payload_weight` duplicated + * `wagon_types.tare_weight_tons` / `wagon_types.capacity_tons` on all 1100 rows, + * with nothing keeping them in step. They had drifted completely: every wagon + * disagreed with its type's tare (seeded ~20T against a real 22.4T NW5), and a + * third disagreed on payload (NW5 wagons claiming 22T–70T against a flat 70T). + * None of those numbers came from the railway. + * + * Nothing reads them for capacity — that math resolves tare and capacity through + * `wagon_type_id` — so dropping them removes a source of fiction rather than a + * source of truth. `wagon_type_id` is NOT NULL with no orphans, so the type is + * always reachable. + * + * A wagon re-tared after repair would need a nullable override column on + * `wagons` falling back to the type; deliberately not added, since no such + * per-wagon value exists today. + */ +export class DropWagonSpecColumns2080000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS tare_weight, + DROP COLUMN IF EXISTS max_payload_weight; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Re-add nullable, backfill from the owning type, then restore NOT NULL. + // The pre-drop values were drifted seed data and are not recoverable — the + // type's spec is what they should always have held. + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS tare_weight NUMERIC(10, 2), + ADD COLUMN IF NOT EXISTS max_payload_weight NUMERIC(10, 2); + `); + await queryRunner.query(` + UPDATE freight.wagons w + SET tare_weight = t.tare_weight_tons, + max_payload_weight = t.capacity_tons + FROM freight.wagon_types t + WHERE t.id = w.wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + ALTER COLUMN tare_weight SET NOT NULL, + ALTER COLUMN max_payload_weight SET NOT NULL; + `); + } +} 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 de57b778b..8ca6ba42d 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 @@ -1900,7 +1900,7 @@ export class TrainSchedulingService { ${esc(wagon.physicalWagon?.wagonNumber)} ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} - ${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))} + ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} ${esc(Number(wagon.capacityTons || 0).toFixed(3))} ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} ${esc(booking?.companyId)} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index 03a930b11..d1939c9f5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -1,5 +1,5 @@ import { WagonStatus } from '@edr/types'; -import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator'; +import { IsString, IsUUID, IsOptional, IsInt, Min, IsEnum } from 'class-validator'; export class CreateWagonDto { @IsString() @@ -17,13 +17,8 @@ export class CreateWagonDto { @Min(1) sequenceNumber?: number; - @IsNumber() - @Min(0) - tareWeight!: number; - - @IsNumber() - @Min(0) - maxPayloadWeight!: number; + // Tare weight and payload capacity are not accepted here: they belong to the + // wagon type and are resolved through wagonTypeId. @IsOptional() @IsEnum(WagonStatus) diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 195b4932b..9f2d41416 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -7,6 +7,7 @@ import { TrainSchedule } from '../../train-schedules/entities/train-schedule.ent import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { Container } from '../../container-management/entities/container.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; export const WAGON_STATUSES = [ WagonStatus.Available, @@ -28,17 +29,19 @@ export class Wagon extends BaseEntity { @Column({ name: 'wagon_type_id', type: 'uuid' }) wagonTypeId!: string; + /** Owns this wagon's spec: tare weight, payload capacity, length. */ + @ManyToOne(() => WagonType) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId!: string | null; @Column({ name: 'sequence_number', type: 'int', nullable: true }) sequenceNumber!: number | null; - @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) - tareWeight!: number; - - @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) - maxPayloadWeight!: number; + // Tare weight and payload capacity are properties of the wagon TYPE — read them + // through `wagonType`, never off the individual wagon. @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 9d1f1b41f..b010c0351 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -52,14 +52,23 @@ export class WagonsService { }); } - const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '') + // Spec columns (tare, payload) are no longer sortable here — they live on the + // wagon type, so sorting by them is sorting by wagonTypeId. + const sortable: Array = [ + 'wagonNumber', + 'status', + 'currentYardId', + 'sequenceNumber', + 'wagonTypeId', + ]; + const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon) ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.wagonRepo.find({ where: search ? where : filters, - relations: { currentYard: true }, + relations: { currentYard: true, wagonType: true }, order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, @@ -69,7 +78,7 @@ export class WagonsService { async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, - relations: { currentYard: true }, + relations: { currentYard: true, wagonType: true }, }); if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); return wagon; diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index 4b3479327..a8abee67b 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -419,8 +419,6 @@ async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: nu wagonTypeId, trainId: null, sequenceNumber: sequenceNo, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Assigned, currentYardId: yardId, currentTrainScheduleId: scheduleId, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 35e098f91..4f801330a 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -306,8 +306,6 @@ async function main() { wagonTypeId: wagonType.id, trainId: null, sequenceNumber: sequenceNo, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Assigned, currentYardId: indode.id, notes: 'Demo wagon for Negad to Indode marshalling', diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 570036f24..3720c0e2a 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -504,8 +504,6 @@ export class DemoBookingsSeeder { wagonTypeId: nw5.id, trainId: null, sequenceNumber: null, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Available, currentYardId: index % 2 === 0 ? djibouti.id : addis.id, notes: "Demo wagon for train scheduling", diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index da2da818f..45c374332 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -76,15 +76,11 @@ export class DemoFreightDataSeeder { } const toCreate = MIN_WAGONS_PER_TYPE - existing; - const tare = Number(type.tareWeightTons ?? 20); - const maxPayload = Number(type.capacityTons ?? 60); const rows = Array.from({ length: toCreate }, (_, i) => { const seq = existing + i + 1; return wagonRepo.create({ wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`, wagonTypeId: type.id, - tareWeight: tare, - maxPayloadWeight: maxPayload, status: WagonStatus.Available, }); }); diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts index 53d6c9eec..eec158856 100644 --- a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -203,7 +203,6 @@ export class MarshallingDemoTrainsSeeder { const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0); const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; const wagonLength = Number(refs.wagonType.lengthMeters) || 14; - const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; const trainSet = await trainSetRepo.save( trainSetRepo.create({ @@ -275,8 +274,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: refs.wagonType.id, yardId: originYard.id, trainScheduleId: schedule.id, - tareWeight, - capacityTons: wagonCapacity, dispatched: hasDeparted, }); @@ -420,8 +417,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: string; yardId: string; trainScheduleId: string; - tareWeight: number; - capacityTons: number; dispatched: boolean; }): Promise { const repo = this.dataSource.getRepository(Wagon); @@ -433,8 +428,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: input.wagonTypeId, currentYardId: input.yardId, currentTrainScheduleId: input.trainScheduleId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, status: WagonStatus.Assigned, notes: 'Marshalling demo seed wagon', }), diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 2f8fa67fd..7ce03b96b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -107,6 +107,10 @@ const normalizePayload = (values: Record) => .filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)), ); +/** Render a spec value inherited from the wagon type; em dash when the type isn't loaded. */ +const fmtTypeSpec = (value: number | undefined | null, unit: string) => + value == null ? '—' : `${Number(value)} ${unit}`; + const extractBackendErrors = (error: unknown) => { const responseData = (error as { response?: { data?: unknown } })?.response?.data; const data = responseData && typeof responseData === 'object' ? responseData as Record : undefined; @@ -922,7 +926,17 @@ export function WagonsCrudPage() { ? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})` : '-', }, - { key: 'maxPayloadWeight', label: 'Max payload' }, + { + // Read-only: the spec lives on the wagon type, so it is displayed, never edited here. + key: 'tareWeight', + label: 'Tare weight', + render: (wagon) => fmtTypeSpec(wagon.wagonType?.tareWeightTons, 't'), + }, + { + key: 'maxPayloadWeight', + label: 'Max payload', + render: (wagon) => fmtTypeSpec(wagon.wagonType?.capacityTons, 't'), + }, { key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) }, ]} fields={[ @@ -933,11 +947,6 @@ export function WagonsCrudPage() { type: 'select', required: true, options: wagonTypeOptions, - onValueChange: (value, current) => { - const selectedType = wagonTypes.find((type: any) => type.id === value); - if (!selectedType || Number(current.maxPayloadWeight) > 0) return {}; - return { maxPayloadWeight: Number(selectedType.capacityTons) }; - }, }, { key: 'currentLocationYardId', @@ -946,8 +955,6 @@ export function WagonsCrudPage() { required: true, options: yardOptions, }, - { key: 'tareWeight', label: 'Tare weight', type: 'number', required: true }, - { key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true }, { key: 'status', label: 'Status', @@ -963,7 +970,7 @@ export function WagonsCrudPage() { }, { key: 'notes', label: 'Notes' }, ]} - emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }} + emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', status: 'AVAILABLE', notes: '' }} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 9e6ef7169..091e056b7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -263,17 +263,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ cardSubtitleKey: "currentYard", searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"], columns: [ + // Tare weight and payload capacity are not wagon columns — they belong to the + // wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages). { id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" }, { id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" }, - { id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" }, { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], formFields: [ { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, - { name: "tareWeight", label: "Tare weight", type: "number", required: true }, - { name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, { name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS }, { name: "notes", label: "Notes", type: "textarea" }, @@ -281,8 +280,6 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ emptyValues: { wagonNumber: "", wagonTypeId: "", - tareWeight: 0, - maxPayloadWeight: 0, currentYardId: "", status: Freight.WagonStatus.Available, notes: "", diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index a200195e0..e30320988 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -15,14 +15,16 @@ export interface Wagon { label: string; country?: string; } | null; + /** Owns this wagon's spec — tare, capacity, length are read from here, never off the wagon. */ wagonType?: { id: string; code: string; name: string; supportedLoadTypes?: string[]; + tareWeightTons?: number; + capacityTons?: number; + lengthMeters?: number; } | null; - tareWeight: number; - maxPayloadWeight: number; status: Freight.WagonStatus; currentYardId: string | null; currentYard?: { id: string; label?: string; code?: string } | null; From c6198a4c627fb2b20d8243cb2ed2dbce39f95320 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 07:49:53 +0000 Subject: [PATCH 14/31] Implement client-side validation for container and bulk cargo fields in booking form --- .../contracts/GlCreateBookingForm.tsx | 166 +++++++++++++++--- .../src/services/contracts.service.ts | 19 +- 2 files changed, 160 insertions(+), 25 deletions(-) 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 547e9ce9f..a744453c0 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -64,6 +64,21 @@ import { /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; +// ISO 6346: 4-letter owner/category code + 6-digit serial + check digit. +// Same rule the customer portal shipment form enforces. +const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; + +interface UnitErrors { + containerNumber?: string; + vgmTons?: string; +} + +interface BulkErrors { + quantity?: string; + hazardous?: string; + reefer?: string; +} + function fmtWindowOpensAt(iso: string): string { const date = new Date(iso).toLocaleDateString("en-GB", { weekday: "short", @@ -372,6 +387,72 @@ export default function GlCreateBookingForm() { prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), ); + // Same client-side validation as the customer portal shipment form: ISO + // container numbers (unique within the shipment) and a positive VGM per unit; + // bulk needs a positive quantity with hazardous/reefer portions bounded by it. + const [showErrors, setShowErrors] = useState(false); + + const unitErrors = useMemo(() => { + if (!isContainer) return []; + const numberCounts = new Map(); + containerLines.forEach((line) => + line.units.forEach((u) => { + const key = u.containerNumber.trim().toUpperCase(); + if (!key) return; + numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1); + }), + ); + return containerLines.map((line) => + line.units.map((u) => { + const errs: UnitErrors = {}; + const key = u.containerNumber.trim().toUpperCase(); + if (!key) { + errs.containerNumber = "Container number is required."; + } else if (!ISO_CONTAINER_NUMBER_REGEX.test(key)) { + errs.containerNumber = + "Enter a valid ISO container number (e.g. ABCD1234567)."; + } else if ((numberCounts.get(key) ?? 0) > 1) { + errs.containerNumber = "Duplicate container number in this shipment."; + } + const vgm = Number(u.vgmTons); + if (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) { + errs.vgmTons = "Enter a valid VGM."; + } + return errs; + }), + ); + }, [isContainer, containerLines]); + + const bulkErrors = useMemo(() => { + if (isContainer) return []; + return bulkLines.map((line) => { + const errs: BulkErrors = {}; + const qty = Number(line.cargoWeightTons || line.itemCount || 0); + if (Number.isNaN(qty) || qty <= 0) { + errs.quantity = "Enter a quantity greater than 0."; + } + const h = Number(line.hazardousQuantity || 0); + if (Number.isNaN(h) || h < 0) { + errs.hazardous = "Enter a valid hazardous quantity."; + } else if (qty > 0 && h > qty) { + errs.hazardous = `Can't exceed the cargo quantity (${qty}).`; + } + const r = Number(line.reeferQuantity || 0); + if (Number.isNaN(r) || r < 0) { + errs.reefer = "Enter a valid refrigerated quantity."; + } else if (qty > 0 && r > qty) { + errs.reefer = `Can't exceed the cargo quantity (${qty}).`; + } + return errs; + }); + }, [isContainer, bulkLines]); + + const cargoValid = isContainer + ? unitErrors.every((line) => + line.every((e) => !e.containerNumber && !e.vgmTons), + ) + : bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer); + const canSubmit = windowOpen && Boolean(scheduledDate) && @@ -401,7 +482,7 @@ export default function GlCreateBookingForm() { hazardousQuantity: l.units.filter((u) => u.hazardous).length, reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ - containerNumber: u.containerNumber, + containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, })), @@ -458,6 +539,13 @@ export default function GlCreateBookingForm() { const overweightLines = validation?.overweightLines ?? []; const openPriceModal = () => { + // Surface the per-field errors (portal-parity validation) instead of + // sending an invalid payload to the price preview. + if (!cargoValid) { + setShowErrors(true); + return; + } + setShowErrors(false); setPriceOpen(true); const payload = buildPayload(); if (payload) { @@ -467,7 +555,7 @@ export default function GlCreateBookingForm() { }; const handleSubmit = () => { - if (!contract || !windowOpen) return; + if (!contract || !windowOpen || !cargoValid) return; // Never book past unresolved 20ft pairing hard-blocks. if (pairingErrors.length > 0) return; // A line above the container type's max capacity can never book. @@ -483,8 +571,13 @@ export default function GlCreateBookingForm() { } catch { // Non-fatal } - navigate(`/dashboard/bookings/${booking.id}/clearance`); + } + if (contract.contractKind === "GENERAL") { + // GENERAL per-booking clearance: land on the booking's clearance + // detail — the same page the Shipments tab on the hub opens. + navigate(`/dashboard/clearance/${booking.id}`); } else { + // ONE_TIME customs keeps its clearance on the contract. navigate(`/dashboard/contracts/clearance/${contract.id}`); } }, @@ -692,6 +785,11 @@ export default function GlCreateBookingForm() { label={unitIdx === 0 ? "Container number *" : undefined} placeholder="e.g. MSCU1234567" value={unit.containerNumber} + error={ + showErrors + ? unitErrors[lineIdx]?.[unitIdx]?.containerNumber + : undefined + } onChange={(e) => patchUnit(lineIdx, unitIdx, { containerNumber: e.currentTarget.value, @@ -720,6 +818,11 @@ export default function GlCreateBookingForm() { min={0} decimalScale={2} value={unit.vgmTons} + error={ + showErrors + ? unitErrors[lineIdx]?.[unitIdx]?.vgmTons + : undefined + } onChange={(v) => patchUnit(lineIdx, unitIdx, { vgmTons: v }) } @@ -808,6 +911,7 @@ export default function GlCreateBookingForm() { min={0} decimalScale={2} value={line.cargoWeightTons} + error={showErrors ? bulkErrors[idx]?.quantity : undefined} onChange={(v) => patchBulk(idx, { cargoWeightTons: v })} radius={10} styles={fieldStyles} @@ -818,6 +922,7 @@ export default function GlCreateBookingForm() { placeholder="e.g. 500" min={0} value={line.itemCount} + error={showErrors ? bulkErrors[idx]?.quantity : undefined} onChange={(v) => patchBulk(idx, { itemCount: v })} radius={10} styles={fieldStyles} @@ -828,6 +933,7 @@ export default function GlCreateBookingForm() { label="Hazardous quantity" min={0} value={line.hazardousQuantity} + error={showErrors ? bulkErrors[idx]?.hazardous : undefined} onChange={(v) => patchBulk(idx, { hazardousQuantity: v })} radius={10} styles={fieldStyles} @@ -838,6 +944,7 @@ export default function GlCreateBookingForm() { label="Refrigerated quantity" min={0} value={line.reeferQuantity} + error={showErrors ? bulkErrors[idx]?.reefer : undefined} onChange={(v) => patchBulk(idx, { reeferQuantity: v })} radius={10} styles={fieldStyles} @@ -905,26 +1012,39 @@ export default function GlCreateBookingForm() { marginTop: 24, }} > - - - - + + {showErrors && !cargoValid ? ( + } + mb="sm" + > + Fix the highlighted cargo fields before reviewing the price. + + ) : null} + + + + + (C.OPS_CLEARANCE_FINALIZE(id)), // ── Booking under contract (GL ET — Path B) ── - createBookingUnderContract: ( + // The API returns { booking, warnings } (CreateBookingUnderContractResult) — + // unwrap to the booking itself so callers can use its id directly. + createBookingUnderContract: async ( id: string, payload: Freight.CreateBookingUnderContractDto, - ) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload), + ): Promise<{ id: string; reference: string; warnings?: string[] }> => { + const result = await postContract<{ + booking?: { id: string; reference: string }; + id?: string; + reference?: string; + warnings?: string[]; + }>(C.BOOKINGS(id), payload); + const booking = result.booking ?? result; + return { + id: booking.id ?? "", + reference: booking.reference ?? "", + warnings: result.warnings, + }; + }, /** * Pre-create validation + authoritative price preview: the same From 554756a116d19d825b339c50a4a50f01a7fa6a40 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 9 Jul 2026 11:00:56 +0300 Subject: [PATCH 15/31] Luggage processing,, schedule times and tariff related updates --- .../src/modules/agents/agents.controller.ts | 8 +- .../src/modules/agents/agents.service.ts | 7 + .../excess-baggage.controller.ts | 41 +++--- .../excess-baggage/excess-baggage.dto.ts | 3 +- .../excess-baggage/excess-baggage.service.ts | 2 +- .../src/modules/search/search.service.ts | 106 +++++++++----- .../backoffice/src/app/agents/page.tsx | 34 ++++- .../src/app/excess-baggage/page.tsx | 131 +++++++++++++++++- .../backoffice/src/app/schedules/page.tsx | 9 +- .../backoffice/src/app/tariff-rates/page.tsx | 17 +-- .../backoffice/src/app/tickets/page.tsx | 21 +-- .../backoffice/src/lib/api/index.ts | 1 + .../backoffice/src/lib/utils.ts | 6 +- .../src/app/booking/passengers/page.tsx | 46 +++++- .../portal/src/app/booking/results/page.tsx | 129 +++++------------ 15 files changed, 365 insertions(+), 196 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index d8be18e74..1f7fcd288 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; @@ -34,6 +34,12 @@ export class AgentsController { updateAgent(@Param('id') id: string, @Body() dto: Partial & { active?: boolean }) { return this.service.updateAgent(id, dto); } + + @Delete(':id') + @ApiOperation({ summary: 'Delete agent profile' }) + deleteAgent(@Param('id') id: string) { + return this.service.deleteAgent(id); + } @Post('bookings') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index cd17da441..4ba7afcf1 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -210,4 +210,11 @@ export class AgentsService { }, }); } + + async deleteAgent(id: string) { + const agent = await this.prisma.agent.findUnique({ where: { id } }); + if (!agent) throw new NotFoundException('Agent not found'); + await this.prisma.agent.delete({ where: { id } }); + return { deleted: true }; + } } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 8d01e7113..551cc8881 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { IsInt, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; @@ -26,7 +26,8 @@ export class ExcessBaggageAgentController { @Post() @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) - logCharge(@Body() dto: LogExcessBaggageDto) { + logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) { + dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId; return this.service.logCharge(dto); } @@ -50,24 +51,6 @@ export class ExcessBaggageAgentController { }); } - @Get(':id') - @ApiOperation({ summary: 'Get a single charge by ID (agent polling)' }) - getCharge(@Param('id') id: string) { - return this.service.getCharge(id); - } - - @Post(':id/resend') - @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) - resendLink(@Param('id') id: string) { - return this.service.resendLink(id); - } - - @Patch(':id/waive') - @ApiOperation({ summary: 'Waive a charge (supervisor only)' }) - waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { - return this.service.waiveCharge(id, dto); - } - @Get('allowances') @ApiOperation({ summary: 'List all baggage allowance rules' }) getAllowances() { @@ -92,6 +75,24 @@ export class ExcessBaggageAgentController { return this.service.deleteAllowance(id); } + @Get(':id') + @ApiOperation({ summary: 'Get a single charge by ID (agent polling)' }) + getCharge(@Param('id') id: string) { + return this.service.getCharge(id); + } + + @Post(':id/resend') + @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) + resendLink(@Param('id') id: string) { + return this.service.resendLink(id); + } + + @Patch(':id/waive') + @ApiOperation({ summary: 'Waive a charge (supervisor only)' }) + waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { + return this.service.waiveCharge(id, dto); + } + @Delete(':id') @ApiOperation({ summary: 'Delete excess baggage charge (admin only)' }) deleteCharge(@Param('id') id: string) { diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index 58bf2bf84..4379ae28d 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -3,7 +3,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class LogExcessBaggageDto { @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; - @ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string; + @ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' }) + @IsOptional() @IsString() agentId?: string; @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) @IsInt() @IsPositive() excessWeightKg: number; @ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' }) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 24e09b87c..30ab0fd5e 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -75,7 +75,7 @@ export class ExcessBaggageService { const charge = await this.prisma.excessBaggageCharge.create({ data: { bookingId: dto.bookingId, - agentId: dto.agentId, + agentId: dto.agentId ?? '', excessWeightKg: dto.excessWeightKg, feePerKgMinor, totalMinor, diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 4de0f0d21..a28be295e 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -154,43 +154,54 @@ export class SearchService { ) { const [y, m, d] = dateStr.split('-').map(Number); const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0); - - const now = new Date(); - const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000)); - const daysAfter = 14 - daysBefore; - - const windowStart = new Date(requestedDate); - windowStart.setDate(windowStart.getDate() - daysBefore); - if (windowStart < now) windowStart.setTime(now.getTime()); - - const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); - - const totalPassengers = adultCount + (childCount ?? 0); - const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const now = new Date(); + const totalPassengers = adultCount + (childCount ?? 0); + const NEEDED = 3; - const schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: 'SCHEDULED', - isPackageOnly: false, - OR: [ - { departureAt: { gte: windowStart, lt: requestedDate } }, - { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, - ], - stopTimes: { some: { stationId: originStationId } }, - coachAssignments: { some: {} }, - }, - include: SCHEDULE_INCLUDE, - orderBy: { departureAt: 'asc' }, - }); + const baseWhere = { + status: 'SCHEDULED', + isPackageOnly: false, + stopTimes: { some: { stationId: originStationId } }, + coachAssignments: { some: {} }, + } as const; - const results = await Promise.all( - schedules.map(schedule => - this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) - ) - ); - return results.filter((r): r is NonNullable => !!r && r.hasAvailability); + // Fetch candidates before and after in parallel; take more than needed to + // account for routes that don't serve the destination or have no availability. + const FETCH_LIMIT = NEEDED * 5; + + const [beforeCandidates, afterCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } }, + include: SCHEDULE_INCLUDE, + orderBy: { departureAt: 'desc' }, + take: FETCH_LIMIT, + }), + this.prisma.trainSchedule.findMany({ + where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } }, + include: SCHEDULE_INCLUDE, + orderBy: { departureAt: 'asc' }, + take: FETCH_LIMIT, + }), + ]); + + const pickN = async (candidates: typeof beforeCandidates, limit: number) => { + const out: NonNullable>>[] = []; + for (const schedule of candidates) { + if (out.length >= limit) break; + const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality); + if (r?.hasAvailability) out.push(r); + } + return out; + }; + + const [before, after] = await Promise.all([ + pickN(beforeCandidates, NEEDED), + pickN(afterCandidates, NEEDED), + ]); + + // before was fetched desc (closest first); reverse so result is chronological + return [...before.reverse(), ...after]; } private async searchSchedules( @@ -415,7 +426,7 @@ export class SearchService { } } - const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -638,6 +649,10 @@ export class SearchService { ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); + const nationalityUpper = (nationality ?? '').toUpperCase(); + const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + // Collect seat class IDs from the schedule include for the ID set, // but fetch fresh records from DB so updated baseFareMinor is always current const seatClassIdSet = new Set(); @@ -647,7 +662,14 @@ export class SearchService { } } const freshSeatClasses = await this.prisma.seatClass.findMany({ - where: { id: { in: Array.from(seatClassIdSet) }, isActive: true }, + where: { + id: { in: Array.from(seatClassIdSet) }, + isActive: true, + OR: [ + { nationalityType: null }, + { nationalityType: nationalityType }, + ], + }, }); const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc])); const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor); @@ -725,6 +747,7 @@ export class SearchService { private buildCoachTypeDetails( schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, + nationality?: string, ): Array<{ coachTypeId: string; coachTypeName: string; @@ -749,8 +772,16 @@ export class SearchService { }); } + const nationalityUpper = (nationality ?? '').toUpperCase(); + const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + const entry = coachTypeMap.get(coachType.id)!; - coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name)); + coachType.seatClasses?.forEach((sc: any) => { + // Exclude classes that belong to the wrong nationality type + if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return; + if (faresByClass.some(f => f.seatClassName === sc.name)) entry.classNames.add(sc.name); + }); } const result = []; @@ -769,6 +800,7 @@ export class SearchService { .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); + if (classes.length === 0) continue; result.push({ coachTypeId: coachType.id, coachTypeName: coachType.name, diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index 39e02dc3c..9efd7ab19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -2,11 +2,12 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Edit, Eye } from 'lucide-react'; +import { Plus, Edit, Eye, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Badge from '@/components/ui/Badge'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { agentsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -48,6 +49,19 @@ export default function AgentsPage() { const [editingAgent, setEditingAgent] = useState(null); const [editError, setEditError] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; agent: any | null }>({ isOpen: false, agent: null }); + const [deleteError, setDeleteError] = useState(null); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => agentsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + setDeleteConfirm({ isOpen: false, agent: null }); + setDeleteError(null); + }, + onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete agent'), + }); + const editMutation = useMutation({ mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data), onSuccess: () => { @@ -122,6 +136,12 @@ export default function AgentsPage() { variant: 'secondary' as const, icon: Eye, }, + { + label: 'Delete', + onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); }, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -169,6 +189,18 @@ export default function AgentsPage() { emptyMessage="No agents found" /> + { setDeleteConfirm({ isOpen: false, agent: null }); setDeleteError(null); }} + onConfirm={async () => { if (deleteConfirm.agent) await deleteMutation.mutateAsync(deleteConfirm.agent.id); }} + title="Delete Agent" + message={`Are you sure you want to delete agent ${deleteConfirm.agent?.agentCode}? This action cannot be undone.`} + confirmText="Delete" + isDanger + isLoading={deleteMutation.isPending} + error={deleteError ?? undefined} + /> + {/* Agent Details Modal */} setSelected(null)} title="Agent Details" size="xl"> {selected && (() => { diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 7b5db209a..3c13e952b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -2,13 +2,14 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { RefreshCw, Send, Trash2 } from 'lucide-react'; +import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { excessBaggageApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { useAuthStore } from '@/lib/auth-store'; const STATUS_VARIANT: Record = { PENDING: 'PENDING', @@ -22,9 +23,16 @@ export default function ExcessBaggagePage() { const queryClient = useQueryClient(); const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' }); const [showExtraFilters, setShowExtraFilters] = useState(false); + const user = useAuthStore((s) => s.user); const [waiveModal, setWaiveModal] = useState(null); const [waiveReason, setWaiveReason] = useState(''); const [waiveError, setWaiveError] = useState(null); + const [logModal, setLogModal] = useState(false); + const [logForm, setLogForm] = useState({ bookingId: '', excessWeightKg: '', collectCash: false }); + const [logError, setLogError] = useState(null); + const [resendModal, setResendModal] = useState(null); + const [resendSuccess, setResendSuccess] = useState(false); + const [resendError, setResendError] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['excess-baggage', filters], @@ -37,6 +45,17 @@ export default function ExcessBaggagePage() { }), }); + const logMutation = useMutation({ + mutationFn: (data: any) => excessBaggageApi.logCharge(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); + setLogModal(false); + setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); + setLogError(null); + }, + onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'), + }); + const waiveMutation = useMutation({ mutationFn: ({ id, reason }: { id: string; reason: string }) => excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }), @@ -51,7 +70,12 @@ export default function ExcessBaggagePage() { const resendMutation = useMutation({ mutationFn: (id: string) => excessBaggageApi.resendLink(id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); + setResendSuccess(true); + setResendError(null); + }, + onError: (e: any) => setResendError(e?.response?.data?.message || e?.message || 'Failed to resend link'), }); const deleteMutation = useMutation({ @@ -115,7 +139,7 @@ export default function ExcessBaggagePage() { label: 'Resend Link', icon: Send, variant: 'secondary' as const, - onClick: (c: any) => resendMutation.mutate(c.id), + onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); }, show: (c: any) => c.status === 'PENDING', }, { @@ -145,6 +169,9 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

+ { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}> + Log Excess Luggage +
@@ -194,6 +221,104 @@ export default function ExcessBaggagePage() { emptyMessage="No excess baggage charges found" /> + {/* Log Excess Luggage Modal */} + setLogModal(false)} title="Log Excess Luggage" size="sm"> +
+ {user && ( +
+ Logging as agent: {user.fullName} +
+ )} +
+ + setLogForm({ ...logForm, bookingId: e.target.value })} + /> +
+
+ + setLogForm({ ...logForm, excessWeightKg: e.target.value })} + /> +
+ + {!logForm.collectCash && ( +

+ A payment link will be sent to the passenger's email and phone on file. +

+ )} + {logError &&

{logError}

} +
+ setLogModal(false)}>Cancel + { + if (!logForm.bookingId.trim() || !logForm.excessWeightKg) { + setLogError('Booking ID and excess weight are required'); + return; + } + logMutation.mutate({ + bookingId: logForm.bookingId.trim(), + excessWeightKg: parseInt(logForm.excessWeightKg), + collectCash: logForm.collectCash, + }); + }} + > + {logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'} + +
+
+
+ + {/* Resend Link Modal */} + setResendModal(null)} title="Resend Payment Link" size="sm"> + {resendModal && ( +
+ {resendSuccess ? ( +
+ ✓ Payment link resent successfully. Expiry extended by 20 minutes. +
+ ) : ( + <> +

+ Resend payment link for booking{' '} + {resendModal.booking?.bookingRef}? +

+
+ {resendModal.contactPhone &&
📱 {resendModal.contactPhone}
} + {resendModal.contactEmail &&
✉ {resendModal.contactEmail}
} +
+

Amount: {formatCurrency(resendModal.totalMinor, resendModal.currency)}. Expiry will be extended by 20 minutes.

+ {resendError &&

{resendError}

} + + )} +
+ setResendModal(null)}>Close + {!resendSuccess && ( + resendMutation.mutate(resendModal.id)}> + Resend + + )} +
+
+ )} +
+ {/* Waive Modal */} ( - {new Date(schedule.departureAt).toLocaleString()} + {formatDateTime(schedule.departureAt)} ), }, { @@ -448,7 +449,7 @@ export default function SchedulesPage() { label: 'Arrival', sortable: true, render: (schedule: Schedule) => ( - {new Date(schedule.arrivalAt).toLocaleString()} + {formatDateTime(schedule.arrivalAt)} ), }, { @@ -641,7 +642,7 @@ export default function SchedulesPage() { } }} title="Cancel Schedule" - message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`} + message={`Cancel the schedule departing ${cancelConfirm.item ? formatDateTime(cancelConfirm.item.departureAt) : ''}? Passengers with bookings will need to be notified separately.`} confirmText="Cancel Schedule" isDanger={true} isLoading={cancelScheduleMutation.isPending} @@ -656,7 +657,7 @@ export default function SchedulesPage() { deleteConfirm.isBulk ? `Are you sure you want to delete ${Array.isArray(deleteConfirm.item) ? deleteConfirm.item.length : 0} schedule(s)? This action cannot be undone.` : `Are you sure you want to delete this schedule departing on ${ - deleteConfirm.item ? new Date(deleteConfirm.item.departureAt).toLocaleString() : '' + deleteConfirm.item ? formatDateTime(deleteConfirm.item.departureAt) : '' }?` } confirmText="Delete" diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 653ffddc3..33f39d87e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -169,7 +169,7 @@ export default function TariffRatesPage() { render: (c: any) => {c.coachType?.name || c.coachTypeId}, }, { - key: 'bedPosition', label: 'Berth Position', + key: 'bedPosition', label: 'Bed Position', render: (c: any) => c.bedPosition ? {c.bedPosition} : Standard, @@ -215,8 +215,9 @@ export default function TariffRatesPage() { }, ]; - const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); - const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC'; + const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId) + ?? editingClass?.coachType; + const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || selectedCoachType?.code?.toLowerCase().includes('bed'); return (
@@ -224,7 +225,7 @@ export default function TariffRatesPage() {

Tariff Rates

- Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy + Manage per-km fare rates by nationality, coach type, and bed position per the official EDR tariff policy

{ setEditingClass(null); setFormError(null); setShowModal(true); }}> @@ -237,7 +238,7 @@ export default function TariffRatesPage() { setSearch(e.target.value)} @@ -311,17 +312,17 @@ export default function TariffRatesPage() { {isBedCoach && (
- + + > + + {COUNTRIES.map((c) => ( + + ))} + {errors.passengers?.[index]?.passportCountry && (

{errors.passengers[index]?.passportCountry?.message}

)} @@ -1336,8 +1370,12 @@ export default function PassengersPage() { + {errors.passengers?.[index]?.passportExpiryDate && ( +

{errors.passengers[index]?.passportExpiryDate?.message}

+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 66f91dd60..8639319de 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -197,11 +197,11 @@ export default function ResultsPage() { // Alternatives are surfaced whenever a leg returns no exact-date results. const alternativeOutbound: Schedule[] = !!results && outboundSchedules.length === 0 - ? results?.alternativeOutbound || [] + ? results?.alternativeOutbound || results?.outboundAlternatives || [] : []; const alternativeInbound: Schedule[] = isRoundTrip && !!results && inboundSchedules.length === 0 - ? results?.alternativeInbound || [] + ? results?.alternativeInbound || results?.inboundAlternatives || [] : []; const requestedDate: string = (results && results.requestedDate) || searchData.date; @@ -927,28 +927,19 @@ export default function ResultsPage() { !!results && outboundSchedules.length === 0 && inboundSchedules.length === 0 && - alternativeOutbound.length === 0 && - alternativeInbound.length === 0; + (results?.alternativeOutbound || []).length === 0 && + (results?.alternativeInbound || []).length === 0; if (isRoundTripNoResults) { return (
-
-
-
- +
+
+
+ + No trains found for your selected dates or route.
-

- No trains found -

-

- We couldn't find any trains for your trip. Try adjusting - your dates or route. -

-
@@ -969,24 +960,13 @@ export default function ResultsPage() { {renderClassModal()}
-
-
- +
+
+ + No trains available on {requestedDateLabel}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedDateLabel} - -

-
@@ -1017,22 +997,13 @@ export default function ResultsPage() { return (
-
-
-
- +
+
+
+ + No trains found matching your search. Try adjusting your dates or route.
-

- No trains found -

-

- We couldn't find any trains matching your search criteria.{" "} -
Try adjusting your dates or route. -

-
@@ -1156,29 +1127,13 @@ export default function ResultsPage() { {outboundSchedules.length === 0 && alternativeOutbound.length > 0 && (
-
-
- +
+
+ + No trains on {requestedDate ? format(new Date(`${requestedDate}T00:00:00`), "EEEE, MMMM d") : "your selected date"}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedDate - ? format( - new Date(`${requestedDate}T00:00:00`), - "EEEE, MMMM d, yyyy", - ) - : "your selected date"} - -

-
@@ -1256,29 +1211,13 @@ export default function ResultsPage() { {inboundSchedules.length === 0 && alternativeInbound.length > 0 && (
-
-
- +
+
+ + No trains on {requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedReturnDate - ? format( - new Date(`${requestedReturnDate}T00:00:00`), - "EEEE, MMMM d, yyyy", - ) - : "your selected return date"} - -

-
From f736523afd013bde93ecfb68766cc1674e2e9b98 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 08:13:20 +0000 Subject: [PATCH 16/31] Implement client-side validation for container and bul --- .../modules/routes/entities/route.entity.ts | 13 ++-- .../train-scheduling/booking-batch.service.ts | 26 +++++++ .../booking-window.service.spec.ts | 25 +++++++ .../booking-window.service.ts | 14 ++++ .../detail/ClearanceReviewSection.tsx | 71 ++++++++++++++----- .../bookings/DocumentClearanceDetailPage.tsx | 1 + .../pages/contracts/GlClearanceDetailPage.tsx | 7 +- .../backoffice/src/services/routes.service.ts | 9 ++- 8 files changed, 140 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index a79a54503..23399bb4d 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -39,12 +39,17 @@ export class Route extends BaseEntity { milestones?: RouteMilestone[]; } +/** + * Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa", + * not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the + * machine identifier and is only a fallback for a yard missing one. + */ export function formatRouteLabel(route: { - originYard?: { code?: string; name?: string } | null; - destinationYard?: { code?: string; name?: string } | null; + originYard?: { code?: string; label?: string } | null; + destinationYard?: { code?: string; label?: string } | null; }): string { - const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin'; - const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination'; + const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin'; + const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination'; return `${origin} → ${dest}`; } 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 cc9e48018..5fb2e7943 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 @@ -2503,6 +2503,32 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * A reservation on this schedule still has time left to pay. + * + * The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt` + * is stamped when the phase starts, then `reserve()` gives each booking + * `now + paymentWindow` a few hundred milliseconds later, one booking at a time. So + * the first settle after the phase deadline finds every reservation still in date, + * expires nothing, reports `anySettled = false`, runs no top-up — and the caller + * concludes the cycle out from under customers who still had time to pay. The next + * tick then expires them with no cycle left to promote the waiting list into. + * + * Callers must not conclude the cycle while this returns true. + */ + async hasLiveReservations(scheduleId: string): Promise { + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); + const now = Date.now(); + return reserved.some( + (b) => + b.paymentStatus !== "PAID" && + b.status !== "PAID" && + b.paymentDeadline != null && + b.paymentDeadline.getTime() > now, + ); + } + /** No wagon slots left for allocated + reserved bookings. */ async isScheduleFull(scheduleId: string): Promise { const schedule = diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index f96c388f8..3286da0eb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => { expireUnacceptedForRouteDay: jest.Mock; settleDueReservations: jest.Mock; isScheduleFull: jest.Mock; + hasLiveReservations: jest.Mock; + refreshWindowStatus: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => { expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined), settleDueReservations: jest.fn().mockResolvedValue(undefined), isScheduleFull: jest.fn().mockResolvedValue(false), + // No reservation is mid-pay-window by default, so the cycle concludes. + hasLiveReservations: jest.fn().mockResolvedValue(false), + refreshWindowStatus: jest.fn().mockResolvedValue(undefined), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => { expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); }); + it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => { + // `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each + // booking's own deadline milliseconds later. So the phase deadline always passes + // first, and concluding here would kill customers who still had time to pay — and + // leave no cycle for the waiting-list top-up to run in. + batch.hasLiveReservations.mockResolvedValue(true); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'), + }); + + const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z')); + + expect(advanced).toBe(true); + expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); + // Still PAYMENT — the cycle was NOT concluded and the window did not reopen. + expect(s.windowPhase).toBe('PAYMENT'); + expect(batch.isScheduleFull).not.toHaveBeenCalled(); + }); + it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => { batch.isScheduleFull.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'PAYMENT' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 03e1d12ca..a730b6aa6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -322,6 +322,20 @@ export class BookingWindowService implements OnModuleInit { return true; } + // `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own + // deadline is set milliseconds later, per booking, so the phase always expires + // a fraction before the reservations it opened. Concluding here would end the + // cycle while customers still had time to pay, and the settle that finally + // expires them (next tick) would have no cycle left to promote the waiting + // list into. Hold in PAYMENT until every reservation has actually resolved. + if (await this.bookingBatchService.hasLiveReservations(schedule.id)) { + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` + + `are still within their pay windows — holding the cycle open`, + ); + return true; + } + await this.concludeCycle(schedule, cfg, now); return true; } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 78cc5efd6..74f9cdb03 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -47,6 +47,12 @@ export interface ClearanceReviewSectionProps { queriesLocked?: boolean; /** Read-only audit view — no approve/query actions. */ readOnly?: boolean; + /** + * GENERAL customs bookings use the phased milestone workflow (same as + * ONE_TIME contracts): hide the legacy output-documents upload block and the + * finalize button — declaration/duty/transit run in the phased action panel. + */ + phasedCustoms?: boolean; } const STATUS_META: Record< @@ -73,6 +79,7 @@ export function ClearanceReviewSection({ approvalsLocked = false, queriesLocked = false, readOnly = false, + phasedCustoms = false, }: ClearanceReviewSectionProps) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); @@ -240,7 +247,7 @@ export function ClearanceReviewSection({ - {clearance.outputCode && ( + {clearance.outputCode && !phasedCustoms && ( )} - {finalizeMutation.isError && ( + {!phasedCustoms && finalizeMutation.isError && ( }> {finalizeMutation.error instanceof Error ? finalizeMutation.error.message @@ -349,8 +356,10 @@ export function ClearanceReviewSection({ )} - - + {phasedCustoms ? ( + // Phased (GENERAL customs) — no legacy finalize; the milestone steps in + // the action panel drive the workflow, same as ONE_TIME contracts. + - + {clearance.allApproved ? ( + + ) : ( + + )} {clearance.allApproved - ? "All required documents are approved — you can finalize." - : "Approve every required document to unlock finalization."} + ? "All required documents are approved. Continue declaration, duty, and transit in the action panel." + : "Approve every required document to unlock the customs milestone steps."} - - - + + ) : ( + + + + + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + )} {viewer} ); 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 a514cd3ec..2cb5404e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() { hideSummary approvalsLocked={isPhasedGeneral && docsPhaseComplete} queriesLocked={queriesLocked} + phasedCustoms={isPhasedGeneral} onChanged={() => void refetch()} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index d5e3f3954..2f42866a9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -188,7 +188,12 @@ export default function GlClearanceDetailPage() { {data.kind === "booking" ? ( - + ) : ( Date: Thu, 9 Jul 2026 11:15:42 +0300 Subject: [PATCH 17/31] Passenger portal build issue resolution --- .../portal/src/app/booking/results/page.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index aa1bf4279..3ef1c8875 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -203,11 +203,6 @@ export default function ResultsPage() { isRoundTrip && !!results && inboundSchedules.length === 0 ? results?.alternativeInbound || results?.inboundAlternatives || [] : []; - const requestedDate: string = - (results && results.requestedDate) || searchData.date; - const requestedReturnDate: string = - (results && results.requestedReturnDate) || searchData.returnDate || ""; - const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; // Round-trip: show results view if either leg has exact results OR alternatives. @@ -965,9 +960,6 @@ export default function ResultsPage() { } if (isOneWayNoOutbound) { - const requestedDateLabel = requestedDate - ? format(new Date(`${requestedDate}T00:00:00`), "EEEE, MMMM d, yyyy") - : "your selected date"; const hasAlternatives = alternativeOutbound.length > 0; return ( From 15707d9070c6aca2e067b9cb728d2e2ea548fcd5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 08:16:59 +0000 Subject: [PATCH 18/31] Implement client-side validation for container and bul --- .../src/modules/train-schedules/train-schedules.repository.ts | 4 +++- .../src/modules/train-scheduling/booking-batch.service.ts | 3 ++- .../src/modules/train-scheduling/train-scheduling.service.ts | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) 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 700a38983..a64faabbe 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 @@ -22,7 +22,9 @@ export class TrainSchedulesRepository extends BaseRepository { return this.repo(manager).findOne({ where: { id }, relations: { - route: true, + // Yards carry the route's display name; without them formatRouteLabel + // degrades to the literal "Origin → Destination". + route: { originYard: true, destinationYard: true }, trainSet: { locomotive: true, locomotives: { locomotive: true }, 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 5fb2e7943..d043fed32 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 @@ -635,7 +635,8 @@ export class BookingBatchService implements OnModuleInit { trainSet: { locomotive: true }, originStation: true, destinationStation: true, - route: true, + // Yards supply the route's display name for `routeName` below. + route: { originYard: true, destinationYard: true }, }, order: { scheduledDepartureDate: "ASC" }, }); 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 8ca6ba42d..d96c8062e 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 @@ -2634,7 +2634,8 @@ export class TrainSchedulingService { const schedules = await this.trainSchedulesRepository.findAll({ relations: { trainSet: { locomotive: true, locomotives: { locomotive: true } }, - route: true, + // Yards carry the route's display name used by mapScheduleListItem. + route: { originYard: true, destinationYard: true }, originStation: true, destinationStation: true, scheduleBookings: { booking: true }, From 5a8db998e513105838e73b63c2d53743c51c3810 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 9 Jul 2026 11:23:14 +0300 Subject: [PATCH 19/31] Alternative schedules notification updates --- .../portal/src/app/booking/results/page.tsx | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 3ef1c8875..33609ba0e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -944,10 +944,11 @@ export default function ResultsPage() { return (
-
-
-
- +
+
+
+ + No trains found for your selected dates or route.
+ + setOpened(false)} + title="Send a password-reset code" + centered + > + + + We'll send a one-time code to this customer's primary contact. + They choose their own new password — you will not see it. + + + setChannel(v as ResetChannel)} + label="Send the code via" + > + + + + + + + + The code goes to the primary contact's own email or phone, which + may differ from the company contact details shown above. + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 2a87e3b0c..d42673d2b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -13,5 +13,9 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { + default as ResetPasswordAction, + type ResetPasswordActionProps, +} from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 77a3bc95e..c6f98c8a2 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -86,6 +86,8 @@ export const URL_CONSTANTS = { `/bookings/by-company/${id}/customer-view`, PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, + RESET_PASSWORD: (companyId: string) => + `/backoffice/customers/${companyId}/reset-password`, }, BILLING: { diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c8bfa7c84..a7d8c16c1 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -62,6 +62,7 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:customers:update", deactivate: "edr_freight_app:customers:deactivate", verify: "edr_freight_app:customers:verify", + resetPassword: "edr_freight_app:customers:reset-password", }, payments: { view: "edr_freight_app:payments:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index c68a4ca25..317f915fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -43,6 +43,7 @@ import { ProfileChips, ProfileStatusBadge, ProfileTypeBadge, + ResetPasswordAction, TableCard, formatBytes, formatDate, @@ -573,6 +574,7 @@ export default function CustomerDetailPage() { } + action={} /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 7710e077f..4f04e3358 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -12,6 +12,8 @@ import type { CustomerPayment, PaginatedCompanies, ProfileStatus, + ResetChannel, + ResetPasswordResult, } from "@/types/customer"; import { CreateDropdownOptionDto, @@ -2261,6 +2263,16 @@ export const api = { ({ id }) => QUERY_KEYS.CUSTOMERS.payments(id), ), + resetPassword: endpoint< + { companyId: string; channel: ResetChannel }, + ResetPasswordResult + >( + "customers", + "resetPassword", + ({ companyId, channel }) => + customersService.resetPassword(companyId, channel), + ), + setProfileStatus: endpoint< { profileId: string; status: ProfileStatus; note?: string }, CompanyProfile diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index cafe0aece..0476d41bb 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -11,6 +11,8 @@ import type { CustomerPayment, PaginatedCompanies, ProfileStatus, + ResetChannel, + ResetPasswordResult, } from "@/types/customer"; const cleanParams = (params: object) => @@ -81,6 +83,22 @@ export const customersService = { .then((r) => r.data); }, + /** + * Send a password-reset code to the company's primary contact. Staff never + * receive a credential — the customer sets their own password from the code. + */ + resetPassword( + companyId: string, + channel: ResetChannel, + ): Promise { + return apiClient + .post( + URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId), + { channel }, + ) + .then((r) => r.data); + }, + setProfileStatus( profileId: string, status: ProfileStatus, diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8d76571d4..7328decf0 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -99,6 +99,15 @@ export interface CompanyChangeRequest { updatedAt: string; } +/** The channel a customer's password-reset code is delivered over. */ +export type ResetChannel = "email" | "phone"; + +export interface ResetPasswordResult { + channel: ResetChannel; + /** Where the code went, e.g. `+251•••4821` — safe to show to staff. */ + maskedTarget: string; +} + /** Mirrors backend `Company` (+ its `companyProfiles`). */ export interface Company { id: string; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 697c2f89c..e4c5685fd 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; +import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage"; import LoginPage from "./pages/accounts/LoginPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; @@ -252,6 +253,7 @@ const App = () => { }> } /> } /> + } /> {/* Signup-flow pages; reached while a session already exists */} diff --git a/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx new file mode 100644 index 000000000..b61bb2eab --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx @@ -0,0 +1,179 @@ +import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core"; +import { + AlertCircle, + ArrowLeft, + Mail, + RotateCw, + ShieldCheck, + Smartphone, +} from "lucide-react"; + +import { maskEmail, maskPhone } from "@/utils/identifier"; + +export type OtpChannel = "phone" | "email"; + +export const OTP_LENGTH = 6; + +export interface OtpChannelSelectProps { + value: OtpChannel; + onChange: (channel: OtpChannel) => void; + disabled?: boolean; + label?: string; +} + +/** Phone/email toggle deciding where the verification code is sent. */ +export function OtpChannelSelect({ + value, + onChange, + disabled, + label = "Send verification code via", +}: OtpChannelSelectProps) { + return ( +
+ + {label} + + onChange(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + /> +
+ ); +} + +export interface OtpChannelStepProps { + channel: OtpChannel; + /** Raw email or phone the code went to; masked before display. */ + target: string; + value: string; + onChange: (otp: string) => void; + onVerify: () => void; + onBack: () => void; + onResend: () => void; + /** Seconds until resend is allowed; 0 enables the button. */ + resendIn: number; + sending: boolean; + verifying: boolean; + error: string | null; + title?: string; + description?: string; + submitLabel: string; +} + +/** + * The "enter the code we sent you" stage. Shared by signup and the + * forgot-password flow — both send through the same `/api/otp/*` service. + */ +export default function OtpChannelStep({ + channel, + target, + value, + onChange, + onVerify, + onBack, + onResend, + resendIn, + sending, + verifying, + error, + title, + description, + submitLabel, +}: OtpChannelStepProps) { + const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target); + const busy = sending || verifying; + + return ( + +
+ + + +
+ +
+

+ {title ?? `Verify your ${channel === "email" ? "email" : "phone"}`} +

+

+ We sent a {OTP_LENGTH}-digit code to{" "} + {maskedTarget}.{" "} + {description ?? "Enter it to continue."} +

+
+ + {error ? ( + }> + {error} + + ) : null} + + + + Verification code + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx b/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx new file mode 100644 index 000000000..89cea7a0e --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx @@ -0,0 +1,41 @@ +import { Check, X } from "lucide-react"; + +import { passwordRequirements } from "@/utils/passwordSchema"; + +export interface PasswordChecklistProps { + /** The current password value; the checklist hides itself when empty. */ + value: string; +} + +/** Live pass/fail list of the password rules, shown under a password field. */ +export default function PasswordChecklist({ value }: PasswordChecklistProps) { + if (!value) return null; + + return ( +
+ {passwordRequirements.map((req) => { + const met = req.test(value); + return ( +
+ + {met ? ( + + ) : ( + + )} + + + {req.label} + +
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 89a3d57e7..5dfda7e58 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -5,6 +5,8 @@ export const URL_CONSTANTS = { REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", + FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request", + FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify", }, USERS: { diff --git a/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts new file mode 100644 index 000000000..deeca8125 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +/** Seconds a user must wait before another OTP can be requested. */ +const DEFAULT_COOLDOWN_SECONDS = 60; + +/** + * Countdown that gates the "Resend code" button. Ticks with setTimeout rather + * than wall-clock arithmetic, so it needs no Date.now(). + */ +export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) { + const [secondsLeft, setSecondsLeft] = useState(0); + + useEffect(() => { + if (secondsLeft <= 0) return; + const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [secondsLeft]); + + return { + secondsLeft, + start: () => setSecondsLeft(seconds), + reset: () => setSecondsLeft(0), + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForgotPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForgotPasswordPage.tsx new file mode 100644 index 000000000..58cfeab43 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForgotPasswordPage.tsx @@ -0,0 +1,312 @@ +import { type FormEvent, useState } from "react"; +import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; + +import { useResendCooldown } from "@/hooks/useResendCooldown"; +import AuthShell from "@/components/auth/AuthShell"; +import OtpChannelStep, { + OTP_LENGTH, + OtpChannelSelect, + type OtpChannel, +} from "@/components/auth/OtpChannelStep"; +import PasswordChecklist from "@/components/auth/PasswordChecklist"; +import { api } from "@/services/api"; +import type { ResetTicket } from "@/types/auth"; +import { normaliseIdentifier } from "@/utils/identifier"; +import { meetsAllRequirements } from "@/utils/passwordSchema"; +import { extractApiError } from "@/utils/result"; + +type Stage = "identify" | "otp" | "password"; + +export default function ForgotPasswordPage() { + const navigate = useNavigate(); + + const [stage, setStage] = useState("identify"); + const [identifier, setIdentifier] = useState(""); + const [channel, setChannel] = useState("phone"); + const [otpCode, setOtpCode] = useState(""); + // The reset ticket lives in memory only — persisting it would leave a + // password-change credential sitting in localStorage. + const [ticket, setTicket] = useState(null); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const [sending, setSending] = useState(false); + const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(null); + const resendCooldown = useResendCooldown(); + + /** The identifier as the API will see it — normalised once, reused everywhere. */ + const normalised = normaliseIdentifier(identifier); + + const sendCode = async () => { + await api.auth.requestPasswordReset.call({ identifier: normalised, channel }); + setOtpCode(""); + resendCooldown.start(); + }; + + // Stage 1 — ask for a code. The API answers identically for unknown accounts, + // so we always advance; a non-existent identifier simply never receives a code. + const handleIdentify = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setSending(true); + try { + await sendCode(); + setStage("otp"); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setSending(false); + } + }; + + const handleResend = async () => { + setError(null); + setSending(true); + try { + await sendCode(); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setSending(false); + } + }; + + // Stage 2 — trade the code for a single-use ticket. + const handleVerify = async () => { + setError(null); + if (otpCode.trim().length !== OTP_LENGTH) { + setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`); + return; + } + setVerifying(true); + try { + const result = await api.auth.verifyPasswordResetOtp.call({ + identifier: normalised, + channel, + otp: otpCode.trim(), + }); + setTicket(result); + setStage("password"); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setVerifying(false); + } + }; + + // Stage 3 — spend the ticket on IAM's set-password. + const handleReset = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + if (!ticket) { + setError("Your reset session expired. Start again."); + setStage("identify"); + return; + } + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + + setVerifying(true); + try { + await api.auth.resetPassword.call({ + userId: ticket.userId, + // The API matches this against email / username / phone, so the typed + // identifier works regardless of which one it is. + email: normalised, + verificationCode: ticket.verificationCode, + newPassword: password, + confirmPassword, + }); + navigate("/login", { + replace: true, + state: { passwordReset: true }, + }); + } catch (err) { + setError(extractApiError(err).message); + } finally { + setVerifying(false); + } + }; + + const identifierLabel = + channel === "email" ? "the email on your account" : "the phone on your account"; + + return ( + +
+ {stage === "identify" ? ( +
+
+ + + +
+ +
+

+ Forgot your password? +

+

+ Enter your email or phone number and we'll send you a code to + reset it. +

+
+ + + setIdentifier(event.target.value)} + /> + + + +

+ The code goes to {identifierLabel}, which may differ from what you + typed above. +

+ + {error ? ( + }> + {error} + + ) : null} + + + +

+ Remembered it?{" "} + + Back to sign in + +

+
+
+ ) : null} + + {stage === "otp" ? ( + { + setStage("identify"); + setError(null); + }} + onResend={handleResend} + resendIn={resendCooldown.secondsLeft} + sending={sending} + verifying={verifying} + error={error} + title="Enter your reset code" + description="Enter it to choose a new password." + submitLabel="Verify code" + /> + ) : null} + + {stage === "password" ? ( +
+
+

+ Choose a new password +

+

+ Pick something strong you haven't used before. +

+
+ + +
+ setPassword(event.target.value)} + /> + +
+ + setConfirmPassword(event.target.value)} + /> + + {error ? ( + }> + {error} + + ) : null} + + + + +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 885888a05..7e8e512dd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -5,21 +5,11 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import AuthShell from "@/components/auth/AuthShell"; +import { normaliseIdentifier } from "@/utils/identifier"; import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; -/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ -function normaliseIdentifier(raw: string): string { - const v = raw.trim(); - const digits = v.replace(/\D/g, ""); - if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { - const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); - return `+251${local}`; - } - return v.toLowerCase(); -} - export default function LoginPage() { const navigate = useNavigate(); const location = useLocation(); @@ -80,7 +70,7 @@ export default function LoginPage() {
Password Forgot password? diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 87702bdd8..ee8dd1922 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -8,30 +8,20 @@ import { z } from "zod"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; - -const passwordRequirements = [ - { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, - { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, - { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, - { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, -] as const; +import { + PASSWORD_MISMATCH, + confirmPasswordField, + passwordField, + passwordRequirements, + samePassword, +} from "@/utils/passwordSchema"; const passwordSchema = z .object({ - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a special character"), - confirmPassword: z.string().min(1, "Please confirm your password"), + password: passwordField, + confirmPassword: confirmPasswordField, }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], - }); + .refine(samePassword, PASSWORD_MISMATCH); type FormData = z.infer; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 5746eefd5..fe2fb09cd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,50 +1,39 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { Alert, Button, PasswordInput, - PinInput, - SegmentedControl, SimpleGrid, Stack, - Text, TextInput, } from "@mantine/core"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - Check, - Mail, - RotateCw, - ShieldCheck, - Smartphone, - X, -} from "lucide-react"; +import { AlertCircle, ArrowRight } from "lucide-react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; +import { useResendCooldown } from "@/hooks/useResendCooldown"; import type { SignupPayload } from "@/types/auth"; import AuthShell from "@/components/auth/AuthShell"; +import OtpChannelStep, { + OTP_LENGTH, + OtpChannelSelect, + type OtpChannel, +} from "@/components/auth/OtpChannelStep"; +import PasswordChecklist from "@/components/auth/PasswordChecklist"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; +import { + PASSWORD_MISMATCH, + confirmPasswordField, + passwordField, + samePassword, +} from "@/utils/passwordSchema"; import { extractApiError } from "@/utils/result"; -const passwordRequirements = [ - { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, - { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, - { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, - { label: "One number", test: (v: string) => /\d/.test(v) }, - { - label: "One special character", - test: (v: string) => /[^A-Za-z0-9]/.test(v), - }, -] as const; - const userSchema = z .object({ email: z.string().email("Invalid email address"), @@ -61,43 +50,20 @@ const userSchema = z en: z.string().min(2, "Name is required"), am: z.string().nullable(), }), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a special character"), - confirmPassword: z.string().min(1, "Please confirm your password"), + password: passwordField, + confirmPassword: confirmPasswordField, }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], - }); + .refine(samePassword, PASSWORD_MISMATCH); type FormData = z.infer; -/** Mask all but the first 7 chars of an E.164 phone for display. */ -const maskPhone = (p: string) => - p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; - -/** Mask the local part of an email for display (j***e@example.com). */ -const maskEmail = (email: string) => { - const [local, domain] = email.split("@"); - if (!local || !domain) return email; - if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`; - return `${local[0]}***${local[local.length - 1]}@${domain}`; -}; - -type OtpChannel = "phone" | "email"; - export default function SignupPage() { const navigate = useNavigate(); const { signup } = useAuth(); const [error, setError] = useState(null); - // Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the - // phone number before the account is actually created. The account is only + // Two-stage signup: fill the form, then a mandatory OTP challenge on the + // chosen channel before the account is actually created. The account is only // created after the code is verified — the OTP is a hard requirement. const [stage, setStage] = useState<"form" | "otp">("form"); const [pendingData, setPendingData] = useState(null); @@ -109,14 +75,7 @@ export default function SignupPage() { const [verifying, setVerifying] = useState(false); const [otpCode, setOtpCode] = useState(""); const [otpError, setOtpError] = useState(null); - const [resendIn, setResendIn] = useState(0); - - // Resend cooldown countdown (pure setTimeout ticks — no Date.now needed). - useEffect(() => { - if (resendIn <= 0) return; - const t = setTimeout(() => setResendIn((s) => s - 1), 1000); - return () => clearTimeout(t); - }, [resendIn]); + const resendCooldown = useResendCooldown(); const { register, @@ -170,7 +129,7 @@ export default function SignupPage() { setOtpChannel(channel); setOtpCode(""); setOtpError(null); - setResendIn(60); + resendCooldown.start(); setStage("otp"); } catch (err) { setError(extractApiError(err).message); @@ -190,7 +149,7 @@ export default function SignupPage() { : { phone: pendingData.phone }, ); setOtpCode(""); - setResendIn(60); + resendCooldown.start(); } catch (err) { setOtpError(extractApiError(err).message); } finally { @@ -202,8 +161,8 @@ export default function SignupPage() { const confirmOtp = async () => { if (!pendingData) return; setOtpError(null); - if (otpCode.trim().length !== 6) { - setOtpError("Enter the 6-digit code we sent you."); + if (otpCode.trim().length !== OTP_LENGTH) { + setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`); return; } setVerifying(true); @@ -298,35 +257,11 @@ export default function SignupPage() { disabled={sending} /> -
- - Send verification code via - - setChannel(v as OtpChannel)} - data={[ - { - value: "phone", - label: ( - - Phone - - ), - }, - { - value: "email", - label: ( - - Email - - ), - }, - ]} - /> -
+
- {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- - {met ? ( - - ) : ( - - )} - - - {req.label} - -
- ); - })} -
- ) : null} +
) : ( - -
- - - -
-
-

- Verify your {otpChannel === "email" ? "email" : "phone"} -

-

- We sent a 6 - digit code to{" "} - - {otpChannel === "email" - ? maskEmail(pendingData?.email ?? "") - : maskPhone(pendingData?.phone ?? "")} - - .Enter it to finish creating your account. -

-
- - {otpError ? ( - } - > - {otpError} - - ) : null} - - - - Verification code - - - - - - -
- - -
-
+ { + setStage("form"); + setOtpError(null); + }} + onResend={resendOtp} + resendIn={resendCooldown.secondsLeft} + sending={sending} + verifying={verifying} + error={otpError} + description="Enter it to finish creating your account." + submitLabel="Verify & create account" + /> )}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index c5af03f65..b9937501f 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -77,6 +77,9 @@ import type { SetPasswordPayload, SignupPayload, SignupResponse, + ForgotPasswordRequestPayload, + ForgotPasswordVerifyPayload, + ResetTicket, } from "@/types/auth"; // --------------------------------------------------------------------------- @@ -110,6 +113,21 @@ export const api = { "setPassword", authService.setPassword, ), + requestPasswordReset: endpoint( + "auth", + "requestPasswordReset", + authService.requestPasswordReset, + ), + verifyPasswordResetOtp: endpoint( + "auth", + "verifyPasswordResetOtp", + authService.verifyPasswordResetOtp, + ), + resetPassword: endpoint( + "auth", + "resetPassword", + authService.resetPassword, + ), checkAvailability: endpoint( "auth", "checkAvailability", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 3f9ef4e53..3b113878d 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -3,11 +3,14 @@ import type { AuthUser, CheckAvailabilityPayload, CheckAvailabilityResponse, + ForgotPasswordRequestPayload, + ForgotPasswordVerifyPayload, GenerateVerificationCodePayload, LoginPayload, LoginResponse, OtpPayload, OtpResponse, + ResetTicket, SetPasswordPayload, SignupPayload, SignupResponse, @@ -53,6 +56,31 @@ export const authService = { return res.data.data; }, + // The three calls below drive the unauthenticated forgot-password flow. + // Responses under /api/auth are *flattened* by the API's response + // interceptor ({ success, ...payload }), so there is no `.data.data` here. + + requestPasswordReset: async (body: ForgotPasswordRequestPayload) => { + await client.post(URL_CONSTANTS.AUTH.FORGOT_PASSWORD_REQUEST, body); + }, + + verifyPasswordResetOtp: async (body: ForgotPasswordVerifyPayload) => { + const res = await client.post( + URL_CONSTANTS.AUTH.FORGOT_PASSWORD_VERIFY, + body, + ); + return { userId: res.data.userId, verificationCode: res.data.verificationCode }; + }, + + /** + * Spend the reset ticket. Distinct from `setPassword` above, which the + * authenticated post-signup flow drives through `useAuth` — this one carries + * its own userId/verificationCode and never touches the session. + */ + resetPassword: async (body: SetPasswordPayload) => { + await client.patch(URL_CONSTANTS.USERS.SET_PASSWORD, body); + }, + checkAvailability: async (params: CheckAvailabilityPayload) => { const res = await client.get( URL_CONSTANTS.USERS.CHECK_AVAILABILITY, diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 04357a9ca..2e9a0b611 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -63,6 +63,25 @@ export interface SetPasswordPayload { verificationCode: string; } +/** The channel a password-reset code is delivered over. */ +export type ResetChannel = "email" | "phone"; + +export interface ForgotPasswordRequestPayload { + /** Email, username, or E.164 phone — whatever the user typed, normalised. */ + identifier: string; + channel: ResetChannel; +} + +export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload { + otp: string; +} + +/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */ +export interface ResetTicket { + userId: string; + verificationCode: string; +} + export interface GenerateVerificationCodePayload { email: string; phoneNumber: string; diff --git a/apps/edr-freight-web/portal/src/utils/identifier.ts b/apps/edr-freight-web/portal/src/utils/identifier.ts new file mode 100644 index 000000000..72677f73c --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/identifier.ts @@ -0,0 +1,22 @@ +/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ +export function normaliseIdentifier(raw: string): string { + const v = raw.trim(); + const digits = v.replace(/\D/g, ""); + if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { + const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + return `+251${local}`; + } + return v.toLowerCase(); +} + +/** Mask all but the first 7 chars of an E.164 phone for display. */ +export const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + +/** Mask the local part of an email for display (j***e@example.com). */ +export const maskEmail = (email: string) => { + const [local, domain] = email.split("@"); + if (!local || !domain) return email; + if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`; + return `${local[0]}***${local[local.length - 1]}@${domain}`; +}; diff --git a/apps/edr-freight-web/portal/src/utils/passwordSchema.ts b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts new file mode 100644 index 000000000..d21d3f83e --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +/** Live checklist shown under the password field. Mirrors {@link passwordField}. */ +export const passwordRequirements = [ + { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, + { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, + { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, + { label: "One number", test: (v: string) => /\d/.test(v) }, + { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, +] as const; + +/** + * Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto` + * — a password this accepts but the API rejects surfaces as an opaque 400. + */ +export const passwordField = z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must include an uppercase letter") + .regex(/[a-z]/, "Password must include a lowercase letter") + .regex(/\d/, "Password must include a number") + .regex(/[^A-Za-z0-9]/, "Password must include a special character"); + +export const confirmPasswordField = z.string().min(1, "Please confirm your password"); + +export const samePassword = (data: { password: string; confirmPassword: string }) => + data.password === data.confirmPassword; + +export const PASSWORD_MISMATCH = { + message: "Passwords do not match", + path: ["confirmPassword"], +} as const; + +/** Every requirement in {@link passwordRequirements} is satisfied. */ +export const meetsAllRequirements = (value: string) => + passwordRequirements.every((r) => r.test(value)); From 3f7734fe1607d9a03986ffe7d573b6fe670bf206 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 08:55:06 +0000 Subject: [PATCH 22/31] fixes --- .../src/pages/accounts/SetPasswordPage.tsx | 34 +++++++++++++++---- .../portal/src/pages/accounts/SignupPage.tsx | 6 ++-- .../portal/src/utils/passwordSchema.ts | 20 ++++++----- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index ee8dd1922..04625f2cd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -1,4 +1,13 @@ -import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Alert, + Box, + Button, + Group, + PasswordInput, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRight, Check, LockKeyhole, X } from "lucide-react"; import { useMemo, useState } from "react"; @@ -9,7 +18,6 @@ import { z } from "zod"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; import { - PASSWORD_MISMATCH, confirmPasswordField, passwordField, passwordRequirements, @@ -21,7 +29,10 @@ const passwordSchema = z password: passwordField, confirmPassword: confirmPasswordField, }) - .refine(samePassword, PASSWORD_MISMATCH); + .refine(samePassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); type FormData = z.infer; @@ -44,7 +55,8 @@ export default function SetPasswordPage() { const password = watch("password"); const requirements = useMemo( - () => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), + () => + passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), [password], ); @@ -83,11 +95,21 @@ export default function SetPasswordPage() { "Secure freight operations", "Advanced authentication system", ], - stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" }, + stats: { + label: "Security Protection", + value: "256-bit", + footer: "Encrypted", + progress: "w-[98%]", + }, }} > - + diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index fe2fb09cd..72dafe9f7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -27,7 +27,6 @@ import PasswordChecklist from "@/components/auth/PasswordChecklist"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; import { - PASSWORD_MISMATCH, confirmPasswordField, passwordField, samePassword, @@ -53,7 +52,10 @@ const userSchema = z password: passwordField, confirmPassword: confirmPasswordField, }) - .refine(samePassword, PASSWORD_MISMATCH); + .refine(samePassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); type FormData = z.infer; diff --git a/apps/edr-freight-web/portal/src/utils/passwordSchema.ts b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts index d21d3f83e..207d9dc2b 100644 --- a/apps/edr-freight-web/portal/src/utils/passwordSchema.ts +++ b/apps/edr-freight-web/portal/src/utils/passwordSchema.ts @@ -6,7 +6,10 @@ export const passwordRequirements = [ { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, + { + label: "One special character", + test: (v: string) => /[^A-Za-z0-9]/.test(v), + }, ] as const; /** @@ -21,15 +24,14 @@ export const passwordField = z .regex(/\d/, "Password must include a number") .regex(/[^A-Za-z0-9]/, "Password must include a special character"); -export const confirmPasswordField = z.string().min(1, "Please confirm your password"); +export const confirmPasswordField = z + .string() + .min(1, "Please confirm your password"); -export const samePassword = (data: { password: string; confirmPassword: string }) => - data.password === data.confirmPassword; - -export const PASSWORD_MISMATCH = { - message: "Passwords do not match", - path: ["confirmPassword"], -} as const; +export const samePassword = (data: { + password: string; + confirmPassword: string; +}) => data.password === data.confirmPassword; /** Every requirement in {@link passwordRequirements} is satisfied. */ export const meetsAllRequirements = (value: string) => From db35b90b45c59c928d67ce2b46fe681175ed7756 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 09:02:44 +0000 Subject: [PATCH 23/31] freight_feature/usermanagement --- .../contracts/GlDjiboutiClearanceListPage.tsx | 128 ++++++++++++++---- 1 file changed, 99 insertions(+), 29 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 733c882bb..afa15d8d1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,40 +1,100 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; -import { ChevronRight, PackageCheck, Ship } from "lucide-react"; +import { + Badge, + Box, + Card, + Group, + Loader, + SegmentedControl, + Stack, + Text, +} from "@mantine/core"; +import { ChevronRight, FileSignature, PackageCheck, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings"; +type QueueTab = "contracts" | "shipments"; + +const prettyStatus = (s?: string | null) => + (s ?? "") + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +/** + * GL Djibouti clearance queues: + * - Contracts: ONE_TIME customs contracts in phased clearance (legacy flow). + * - Shipments: GENERAL-contract bookings in per-booking clearance awaiting a DJ + * action (DO collection after ET finalizes pre-clearance, RO for exports, + * loading milestones). Managed like the one-time flow, but per booking. + */ export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); + const [tab, setTab] = useState("shipments"); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue(); const contractItems = contractQueue?.items ?? []; const bookingItems = bookingQueue ?? []; + const isLoading = tab === "contracts" ? contractsLoading : bookingsLoading; return ( - - {contractsLoading || bookingsLoading ? ( - - - - ) : ( - - {contractItems.length === 0 && bookingItems.length === 0 ? ( - - No Djibouti customs work yet. - - ) : ( - <> - {contractItems.map((c) => ( + + + + setTab(v as QueueTab)} + radius="md" + data={[ + { + value: "shipments", + label: ( + + + Shipments + + {bookingItems.length} + + + ), + }, + { + value: "contracts", + label: ( + + + Contracts + + {contractItems.length} + + + ), + }, + ]} + /> + + {isLoading ? ( + + + + ) : tab === "contracts" ? ( + + {contractItems.length === 0 ? ( + + No Djibouti customs contracts yet. + + ) : ( + contractItems.map((c) => ( {c.reference} - {c.tradeDirection} · {c.status} + {c.tradeDirection} · {prettyStatus(c.status)}
@@ -61,15 +121,24 @@ export default function GlDjiboutiClearanceListPage() { - ))} - {bookingItems.map((b) => ( + )) + )} + + ) : ( + + {bookingItems.length === 0 ? ( + + No shipment bookings awaiting a Djibouti action. + + ) : ( + bookingItems.map((b) => ( navigate(`/dashboard/clearance/${b.id}`)} + onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)} > @@ -80,7 +149,8 @@ export default function GlDjiboutiClearanceListPage() {
{b.reference} - {b.tradeDirection} · {b.status} + {b.tradeDirection} · {prettyStatus(b.status)} + {b.company?.name ? ` · ${b.company.name}` : ""}
@@ -92,11 +162,11 @@ export default function GlDjiboutiClearanceListPage() {
- ))} - - )} -
- )} + )) + )} + + )} + ); } From fa5fde6b6bcc96b17eab320b69a08ba640a40d29 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 9 Jul 2026 08:59:58 +0000 Subject: [PATCH 24/31] Fix slow GRN button and sms timeout --- .../strategies/notification.sms.strategy.ts | 5 ++ .../warehouses/warehouse-inventory.service.ts | 47 +++++++++++++++---- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index d8404e84c..cddc67b2d 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy { this.logger.debug(`Sending SMS to ${recipient} via ${url}`); + // axios defaults to no timeout — a hanging gateway would block the caller + // (and any transaction it sits in) indefinitely. Always bound the wait. + const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); + try { const response = await axios.post( url, @@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy { callbackUrl: "", }, { + timeout, headers: { accept: "*/*", "Content-Type": "application/json", 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 9b2f53a99..06e84d455 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 @@ -860,6 +860,25 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + /** Sent after the transaction commits so the gateway never blocks the receive. */ + const pendingNotifications: Array<{ + owner: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }; + booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }; + bookingId: string; + }> = []; await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -1032,21 +1051,33 @@ export class WarehouseInventoryService { manager, ); - await this.notifyOwnerInventoryReceived({ - phone: truckEntrance?.customerPhone ?? booking.customerPhone, - ownerName: truckEntrance?.ownerName ?? booking.customer, - bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, - grnNumber, - direction: dto.direction, - warehouseId: dto.warehouseId, + // Queued, not sent here: an SMS/email round-trip inside the transaction + // holds capacity/location locks open for the whole gateway latency. + pendingNotifications.push({ + owner: { + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }, + booking, + bookingId, }); result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); - void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); + // Fan out after commit, un-awaited: the receive response must not wait on the + // SMS gateway. Both notifiers swallow their own errors. + for (const pending of pendingNotifications) { + void this.notifyOwnerInventoryReceived(pending.owner); + void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId); + } + return result; } From f7ceca944ad50de090983b7a3ca307de5dd2d55b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 9 Jul 2026 09:21:15 +0000 Subject: [PATCH 25/31] Freight OperationsQA Test Plan --- docs/qa/edr-freight-qa-test-plan.pdf | 6680 ++++++++++++++++++++++++++ 1 file changed, 6680 insertions(+) create mode 100644 docs/qa/edr-freight-qa-test-plan.pdf diff --git a/docs/qa/edr-freight-qa-test-plan.pdf b/docs/qa/edr-freight-qa-test-plan.pdf new file mode 100644 index 000000000..56c5d520d --- /dev/null +++ b/docs/qa/edr-freight-qa-test-plan.pdf @@ -0,0 +1,6680 @@ +%PDF-1.3 +%ºß¬à +3 0 obj +<> +endobj +4 0 obj +<< +/Length 9916 +>> +stream +0.200025 w +0 G +BT +/F5 8 Tf +9.1999999999999993 TL +0.043 0.333 0.388 rg +42. 793.8899999999999864 Td +(ETHIO-DJIBOUTI RAILWAY / FREIGHT PLATFORM) Tj +ET +BT +/F9 30 Tf +34.5 TL +0.063 0.102 0.11 rg +42. 767.8899999999999864 Td +(Freight Operations) Tj +ET +BT +/F9 30 Tf +34.5 TL +0.063 0.102 0.11 rg +42. 737.8899999999999864 Td +(QA Test Plan) Tj +ET +0.06 0.1 0.11 RG +1.3999999999999999 w +42. 717.8899999999999864 m +553.2799999999999727 717.8899999999999864 l +S +BT +/F1 10 Tf +11.5 TL +0.275 0.345 0.357 rg +42. 697.8899999999999864 Td +(End-to-end test flows from booking through warehouse, rail, and delivery - import and export,) Tj +T* (with and without first/last mile, self-haul and EDR haulage. Every status, guard, and endpoint) Tj +T* (below is taken from the code, not assumed.) Tj +ET +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.427 0.498 0.51 rg +42. 642.8899999999999864 Td +(BRANCH Truckdetantion) Tj +ET +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.427 0.498 0.51 rg +212. 642.8899999999999864 Td +(SCOPE Warehouse . Fees . Allocation . First mile . Last mile) Tj +ET +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.427 0.498 0.51 rg +42. 630.8899999999999864 Td +(DEPTH Tester steps + technical refs) Tj +ET +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 596.8899999999999864 Td +(00) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 595.8899999999999864 Td +(Test data setup) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 575.8899999999999864 m +553.2799999999999727 575.8899999999999864 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 561.8899999999999864 Td +(Nothing below passes without this. Set it up once per environment and confirm each line before opening a single flow.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 547.3899999999999864 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 539.8899999999999864 Td +(Warehouse tree: at least one ACTIVE warehouse with a yard and a zone. Capacities are in TONNES, not kg.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 530.3899999999999864 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 522.8899999999999864 Td +(Company has a linked portal user. Critical - in-app notifications resolve recipients from the company's portal users. With) Tj +T* (none linked, notify\(\) logs "0 recipients - skipped" and stores nothing. SMS/email still fire.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 501.3899999999999864 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 493.8899999999999864 Td +(Customer saved signature. Required for Approve delivery; without it the API returns "Please save your signature before) Tj +T* (approving delivery".) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 472.3899999999999864 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 464.8899999999999864 Td +(Allocation rules covering the freight type and trade direction under test \(see 08\), or accept the capacity-balanced fallback.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 455.3899999999999864 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 447.8899999999999864 Td +(Fee rules: at least one each of STORAGE_FEE, DEMURRAGE_FEE, DOUBLE_HANDLING_FEE,) Tj +T* (TRUCK_DETENTION_FEE \(see 07\).) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 426.3899999999999864 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 418.8899999999999864 Td +(Drivers and vehicles registered; a train schedule with wagons for the route under test.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 409.3899999999999864 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 401.8899999999999864 Td +(Booking reaches PAID. Receive-to-warehouse skips any booking that is not PAID.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 392.3899999999999864 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 384.8899999999999864 Td +(Container numbers are ISO 6346 - 4 letters + 7 digits, uppercase \(ABCU1234567\). Enforced at booking input and every) Tj +T* (reference point.) Tj +ET +0.89 0.94 0.95 rg +42. 351.8899999999999864 511.2799999999999727 -51. re +f +0.04 0.33 0.39 rg +42. 351.8899999999999864 3. -51. re +f +BT +/F1 8.6 Tf +9.8899999999999988 TL +0.275 0.345 0.357 rg +56. 338.8899999999999864 Td +(Direction is derived, not declared. Receive-to-warehouse computes trade direction from the ORIGIN and DESTINATION YARD) Tj +T* (COUNTRIES, not the booking's stored trade_direction. A booking whose route says IMPORT will be skipped from an EXPORT) Tj +T* (receive with "Booking route is IMPORT, not EXPORT". Set up yards accordingly.) Tj +ET +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 278.8899999999999864 Td +(01) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 277.8899999999999864 Td +(Lifecycle reference) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 257.8899999999999864 m +553.2799999999999727 257.8899999999999864 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 243.8899999999999864 Td +(The three state machines a tester needs to read a failure. Anything not listed as an allowed transition is rejected by) Tj +T* (assertTransition.) Tj +ET +BT +/F2 8 Tf +9.1999999999999993 TL +0.063 0.102 0.11 rg +42. 203.8899999999999864 Td +(WAREHOUSE INVENTORY TRANSITIONS) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 191.8899999999999864 105. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 180.9399999999999409 Td +(From) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +147. 191.8899999999999864 145. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +152. 180.9399999999999409 Td +(Allowed next) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +292. 191.8899999999999864 261.2799999999999159 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +297. 180.9399999999999409 Td +(Notes) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 173.8400000000000318 105. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 162.8899999999999864 Td +(UNLOADED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +147. 173.8400000000000318 145. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +152. 162.8899999999999864 Td +(STORED, READY_FOR_PICKUP) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +292. 173.8400000000000318 261.2799999999999159 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +297. 162.3799999999999955 Td +(Import landing state after train unload) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 155.1000000000000227 105. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 144.1499999999999773 Td +(RECEIVED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +147. 155.1000000000000227 145. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +152. 144.1499999999999773 Td +(STORED, READY_FOR_PICKUP) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +292. 155.1000000000000227 261.2799999999999159 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +297. 143.6399999999999864 Td +(Export landing state after truck receive) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 136.3600000000000136 105. -27.4799999999999969 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 125.4099999999999682 Td +(STORED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +147. 136.3600000000000136 145. -27.4799999999999969 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +152. 125.4099999999999682 Td +(RESERVED, READY_FOR_LOADING) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +292. 136.3600000000000136 261.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +297. 124.8999999999999773 Td +(Reserve is retired from the UI; STORED -> READY_FOR_LOADING is the) Tj +T* (live path) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 108.8799999999999955 105. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 97.92999999999995 Td +(READY_FOR_LOADING) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +147. 108.8799999999999955 145. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +152. 97.92999999999995 Td +(LOADED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +292. 108.8799999999999955 261.2799999999999159 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +297. 97.4199999999999591 Td +(Onto a wagon) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 90.1399999999999864 105. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 79.1899999999999409 Td +(LOADED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +147. 90.1399999999999864 145. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +152. 79.1899999999999409 Td +(DISPATCHED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +292. 90.1399999999999864 261.2799999999999159 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +297. 78.67999999999995 Td +() Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 71.3999999999999773 105. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 60.4499999999999318 Td +(DISPATCHED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +147. 71.3999999999999773 145. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +152. 60.4499999999999318 Td +(UNLOADED_AT_DJIBOUTI_PORT) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +292. 71.3999999999999773 261.2799999999999159 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +297. 59.9399999999999409 Td +(Export only, at Djibouti) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.8 0.85 0.85 RG +0.5 w +42. 36. m +553.2799999999999727 36. l +S +BT +/F9 8 Tf +9.1999999999999993 TL +0.427 0.498 0.51 rg +42. 22. Td +(EDR Freight - QA Test Plan) Tj +ET +BT +/F5 7.5 Tf +8.625 TL +0.427 0.498 0.51 rg +530.7799999999999727 22. Td +(1 / 7) Tj +ET +endstream +endobj +5 0 obj +<> +endobj +6 0 obj +<< +/Length 16091 +>> +stream +0. w +0.78 G +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 801.8899999999999864 105. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 790.9399999999999409 Td +(From) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +147. 801.8899999999999864 145. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +152. 790.9399999999999409 Td +(Allowed next) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +292. 801.8899999999999864 261.2799999999999159 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +297. 790.9399999999999409 Td +(Notes) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 783.8400000000000318 105. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 772.8899999999999864 Td +(READY_FOR_PICKUP) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +147. 783.8400000000000318 145. -18.7399999999999984 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +152. 772.8899999999999864 Td +(DELIVERED, STORED, DISPATCHED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +292. 783.8400000000000318 261.2799999999999159 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +297. 772.3799999999999955 Td +(Import; may be put back into storage) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +BT +/F2 8 Tf +9.1999999999999993 TL +0.063 0.102 0.11 rg +42. 743.1000000000000227 Td +(CONTAINER ITEM STAGES) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.5 w +42. 733.1000000000000227 511.2799999999999727 -21. re +B +BT +/F5 8 Tf +9.1999999999999993 TL +0.275 0.345 0.357 rg +52. 721.1000000000000227 Td +(PENDING > RECEIVED > GRN > ASSIGNED > LOADED > LEFT > DELIVERED) Tj +ET +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 700.1000000000000227 Td +(ASSIGNED means the customer picked which containers ride which truck - planning only. LOADED requires the operator to) Tj +T* (actually load them, and only after the truck has arrived \(loaded_at is stamped then\). Assignment alone must never show) Tj +T* (LOADED.) Tj +ET +BT +/F2 8 Tf +9.1999999999999993 TL +0.063 0.102 0.11 rg +42. 650.1000000000000227 Td +(FIRST MILE & LAST MILE) Tj +ET +0.8 0.85 0.85 RG +0.5 w +0.8 0.85 0.85 RG +0.5 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 638.1000000000000227 56. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 627.1499999999999773 Td +(Leg) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +98. 638.1000000000000227 210. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +103. 627.1499999999999773 Td +(Statuses, in order) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +308. 638.1000000000000227 245.2799999999999727 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +313. 627.1499999999999773 Td +(Gate it controls) Tj +ET +0.8 0.85 0.85 RG +0.5 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 620.0499999999999545 56. -26.0999999999999979 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 608.5900000000000318 Td +(First mile) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +98. 620.0499999999999545 210. -26.0999999999999979 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +103. 609.1000000000000227 Td +(PAYMENT_PENDING -> READY_TO_TRANSIT ->) Tj +T* (IN_TRANSIT -> RECEIVED_TO_PORT) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +308. 620.0499999999999545 245.2799999999999727 -26.0999999999999979 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +313. 608.5900000000000318 Td +(Export receive is blocked until RECEIVED_TO_PORT) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 593.9500000000000455 56. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 582.4900000000000091 Td +(Last mile) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +98. 593.9500000000000455 210. -27.4799999999999969 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +103. 583. Td +(PAYMENT_PENDING -> READY_TO_TRANSIT ->) Tj +T* (IN_TRANSIT -> DELIVERED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +308. 593.9500000000000455 245.2799999999999727 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +313. 582.4900000000000091 Td +(Truck-detention window: arrivedAt \(reached destination\) -> deliveredAt) Tj +T* (\(vehicle returned\)) Tj +ET +0.8 0.85 0.85 RG +0.5 w +0.78 G +0. w +0.8 0.85 0.85 RG +0.5 w +0.8 0.85 0.85 RG +0.5 w +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 540.4700000000000273 Td +(02) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 539.4700000000000273 Td +(Export - without first mile) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 519.4700000000000273 m +553.2799999999999727 519.4700000000000273 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 505.4700000000000273 Td +(Customer brings the cargo to the facility themselves. The happy path from a paid booking to cargo unloaded at Djibouti port with) Tj +T* (an interchange document.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 471.4700000000000273 34. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 460.5200000000000387 Td +(Step) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +76. 471.4700000000000273 150. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +81. 460.5200000000000387 Td +(Tester action) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +226. 471.4700000000000273 227.2799999999999159 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +231. 460.5200000000000387 Td +(Expected result) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +453.2799999999999159 471.4700000000000273 100. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +458.2799999999999159 460.5200000000000387 Td +(Technical ref) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 453.4200000000000159 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 441.9600000000000364 Td +(E1.1) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 453.4200000000000159 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 441.9600000000000364 Td +(Create an export booking \(route origin ET) Tj +T* (-> destination DJ\), pay it.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 453.4200000000000159 227.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 441.9600000000000364 Td +(Booking reaches PAID.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 453.4200000000000159 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 441.9600000000000364 Td +(Container numbers must) Tj +T* (be ISO 6346) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 425.9399999999999977 34. -44.9599999999999937 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 414.4800000000000182 Td +(E1.2) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 425.9399999999999977 150. -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 414.4800000000000182 Td +(Export Operations > Receive for Loading.) Tj +T* (Select the booking, capture the truck) Tj +T* (entrance \(plate, driver, weights\), pick) Tj +T* (warehouse/yard/zone.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 425.9399999999999977 227.2799999999999159 -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 414.4800000000000182 Td +(Inventory created; GRN issued as) Tj +T* (GRN-EXPORT-YYYYMMDD-XXXXXXXX. Customer gets a) Tj +T* (receive SMS.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 425.9399999999999977 100. -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 414.4800000000000182 Td +(POST /warehouse-invento) Tj +T* (ry/receive-bulk) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 380.9800000000000182 34. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 369.5200000000000387 Td +(E1.3) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 380.9800000000000182 150. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 369.5200000000000387 Td +(Store the item - either let it auto-allocate,) Tj +T* (or pick warehouse/yard/zone in the Store) Tj +T* (modal.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 380.9800000000000182 227.2799999999999159 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 369.5200000000000387 Td +(Status STORED; note records "allocation rule",) Tj +T* ("capacity-balanced", or "operator-selected".) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 380.9800000000000182 100. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 369.5200000000000387 Td +(Capacity decremented in) Tj +T* (tonnes) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 344.7599999999999909 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 333.3000000000000114 Td +(E1.4) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 344.7599999999999909 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 333.3000000000000114 Td +(Inspect: mark selected items as) Tj +T* (inspected, outcome PASSED.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 344.7599999999999909 227.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 333.3000000000000114 Td +(Export items advance straight to READY_FOR_LOADING.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 344.7599999999999909 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 333.3000000000000114 Td +(Reserve step is retired) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 317.2799999999999727 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 305.8199999999999363 Td +(E1.5) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 317.2799999999999727 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 305.8199999999999363 Td +(Ready To Load tab > load onto the) Tj +T* (allocated wagon.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 317.2799999999999727 227.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 305.8199999999999363 Td +(Status LOADED; a warehouse loading record exists.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 317.2799999999999727 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 305.8199999999999363 Td +(Requires an allocated) Tj +T* (wagon) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 289.7999999999999545 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 278.3399999999999181 Td +(E1.6) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 289.7999999999999545 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 278.3399999999999181 Td +(Download the export marshalling / load) Tj +T* (list PDF.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 289.7999999999999545 227.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 278.3399999999999181 Td +(PDF lists the train's wagons, bookings, containers.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 289.7999999999999545 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 278.3399999999999181 Td +(train-scheduling controller) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 262.3199999999999363 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 250.8599999999999 Td +(E1.7) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 262.3199999999999363 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 250.8599999999999 Td +(Dispatch Queue > dispatch.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 262.3199999999999363 227.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 250.8599999999999 Td +(Status DISPATCHED. Customer receives "Shipment dispatched") Tj +T* (naming origin -> destination.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 262.3199999999999363 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 250.8599999999999 Td +(Per booking on the) Tj +T* (schedule) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 234.8399999999999181 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 223.3799999999998818 Td +(E1.8) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 234.8399999999999181 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 223.3799999999998818 Td +(Move the schedule to arrived at the) Tj +T* (Djibouti-side port.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 234.8399999999999181 227.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 223.3799999999998818 Td +(Train appears in the Djibouti unloading queue. Customer) Tj +T* (receives "Shipment arrived".) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 234.8399999999999181 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 223.3799999999998818 Td +(status ARRIVED /) Tj +T* (ARRIVED_AT_DJIBOUTI) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 207.3599999999999 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 195.8999999999998636 Td +(E1.9) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 207.3599999999999 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 195.8999999999998636 Td +(Grant the gate pass for the train, then) Tj +T* (Unload at Djibouti.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 207.3599999999999 227.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 195.8999999999998636 Td +(Items become UNLOADED_AT_DJIBOUTI_PORT and an) Tj +T* (interchange document is generated.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 207.3599999999999 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 195.8999999999998636 Td +(Unload checks) Tj +T* (gatepass_granted_at) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 179.8799999999998818 34. -27.4799999999999969 re +B +BT +/F6 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 168.4199999999998454 Td +(E1.G) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 179.8799999999998818 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 168.4199999999998454 Td +(Try to unload at Djibouti BEFORE) Tj +T* (granting the gate pass.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +226. 179.8799999999998818 227.2799999999999159 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +231. 168.4199999999998454 Td +(BLOCKED. Items skipped with a gate-pass reason; no) Tj +T* (interchange document.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2799999999999159 179.8799999999998818 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2799999999999159 168.4199999999998454 Td +(See section 06) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.8 0.85 0.85 RG +0.5 w +42. 36. m +553.2799999999999727 36. l +S +BT +/F9 8 Tf +9.1999999999999993 TL +0.427 0.498 0.51 rg +42. 22. Td +(EDR Freight - QA Test Plan) Tj +ET +BT +/F5 7.5 Tf +8.625 TL +0.427 0.498 0.51 rg +530.7799999999999727 22. Td +(2 / 7) Tj +ET +endstream +endobj +7 0 obj +<> +endobj +8 0 obj +<< +/Length 18643 +>> +stream +0.9 w +0.06 0.1 0.11 RG +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 783.8899999999999864 Td +(03) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 782.8899999999999864 Td +(Export - with first mile) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 762.8899999999999864 m +553.2799999999999727 762.8899999999999864 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 748.8899999999999864 Td +(EDR collects the cargo from the customer's premises. Identical to section 02 from the store step onward; the difference is entirely) Tj +T* (in the gate before receive.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 714.8899999999999864 40. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 703.9400000000000546 Td +(Step) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +82. 714.8899999999999864 150. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +87. 703.9400000000000546 Td +(Tester action) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +232. 714.8899999999999864 221.2800000000000296 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +237. 703.9400000000000546 Td +(Expected result) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +453.2800000000000296 714.8899999999999864 100. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +458.2800000000000296 703.9400000000000546 Td +(Technical ref) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 696.8399999999999181 40. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 685.3799999999999955 Td +(E2.1) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +82. 696.8399999999999181 150. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +87. 685.3799999999999955 Td +(Create the export booking with a first-mile) Tj +T* (pickup address \(or a service type that) Tj +T* (includes first mile\).) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +232. 696.8399999999999181 221.2800000000000296 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +237. 685.3799999999999955 Td +(Booking is flagged hasFirstMile.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2800000000000296 696.8399999999999181 100. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2800000000000296 685.3799999999999955 Td +(Derived from address OR ) Tj +T* (service_types.includes_firs) Tj +T* (t_mile) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 660.6200000000000045 40. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 649.1599999999999682 Td +(E2.2) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +82. 660.6200000000000045 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +87. 649.1599999999999682 Td +(Create the first-mile request; assign a) Tj +T* (vehicle and driver.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +232. 660.6200000000000045 221.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +237. 649.1599999999999682 Td +(Driver receives an SMS naming the vehicle, booking, pickup) Tj +T* (and destination.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2800000000000296 660.6200000000000045 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2800000000000296 649.1599999999999682 Td +(First Mile page) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 633.1399999999999864 40. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 621.67999999999995 Td +(E2.3) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +82. 633.1399999999999864 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +87. 621.67999999999995 Td +(Walk the leg: READY_TO_TRANSIT ->) Tj +T* (IN_TRANSIT -> RECEIVED_TO_PORT.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +232. 633.1399999999999864 221.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +237. 621.67999999999995 Td +(Each transition persists.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2800000000000296 633.1399999999999864 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2800000000000296 621.67999999999995 Td +() Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 605.6599999999999682 40. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 594.2000000000000455 Td +(E2.4) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +82. 605.6599999999999682 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +87. 594.2000000000000455 Td +(Now run Receive for Loading.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +232. 605.6599999999999682 221.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +237. 594.2000000000000455 Td +(Booking is received; truck entrance pre-filled from the first-mile) Tj +T* (vehicle and driver.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2800000000000296 605.6599999999999682 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2800000000000296 594.2000000000000455 Td +(Then continue at E1.3) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 578.1800000000000637 40. -27.4799999999999969 re +B +BT +/F6 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 566.7200000000000273 Td +(E2.G1) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +82. 578.1800000000000637 150. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +87. 566.7200000000000273 Td +(Attempt receive with NO first-mile request) Tj +T* (created.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +232. 578.1800000000000637 221.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +237. 566.7200000000000273 Td +(SKIPPED: "First-mile request not created".) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2800000000000296 578.1800000000000637 100. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2800000000000296 566.7200000000000273 Td +() Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 550.7000000000000455 40. -36.2199999999999989 re +B +BT +/F6 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 539.2400000000000091 Td +(E2.G2) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +82. 550.7000000000000455 150. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +87. 539.2400000000000091 Td +(Attempt receive while first-mile status is) Tj +T* (IN_TRANSIT.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +232. 550.7000000000000455 221.2800000000000296 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +237. 539.2400000000000091 Td +(SKIPPED: "First-mile truck has not arrived".) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +453.2800000000000296 550.7000000000000455 100. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +458.2800000000000296 539.2400000000000091 Td +(Only) Tj +T* (RECEIVED_TO_PORT) Tj +T* (passes) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 488.4800000000000182 Td +(04) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 487.4800000000000182 Td +(Import - self-haul \(customer collects\)) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 467.4800000000000182 m +553.2799999999999727 467.4800000000000182 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 453.4800000000000182 Td +(The longest flow, and the one with the most guards. The customer assigns their own trucks, signs a booking-level handover, and) Tj +T* (collects. Test this one first - it exercises truck arrival, loading, weighing, handover, exit paper, and fees.) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.5 w +42. 421.4800000000000182 511.2799999999999727 -32. re +B +BT +/F5 8 Tf +9.1999999999999993 TL +0.275 0.345 0.357 rg +52. 409.4800000000000182 Td +(train arrives > unload > inspect > ready for pickup > assign truck > truck arrival > sign) Tj +T* (handover > load > truck leaving > exit paper > deliver) Tj +ET +0.8 0.85 0.85 RG +0.5 w +0.8 0.85 0.85 RG +0.5 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 377.4800000000000182 34. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 366.5300000000000296 Td +(Step) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +76. 377.4800000000000182 145. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +81. 366.5300000000000296 Td +(Tester action) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +221. 377.4800000000000182 234.2800000000000296 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +226. 366.5300000000000296 Td +(Expected result) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +455.2800000000000296 377.4800000000000182 98. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +460.2800000000000296 366.5300000000000296 Td +(Technical ref) Tj +ET +0.8 0.85 0.85 RG +0.5 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 359.4300000000000068 34. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 347.9700000000000273 Td +(I1.1) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 359.4300000000000068 145. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 347.9700000000000273 Td +(Import booking \(route DJ -> ET\), paid,) Tj +T* (no last-mile address and a service type) Tj +T* (that excludes last mile.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 359.4300000000000068 234.2800000000000296 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 347.9700000000000273 Td +(Booking is self-haul.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 359.4300000000000068 98. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 347.9700000000000273 Td +(Drives) Tj +T* (usesCustomerTruck) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 323.2100000000000364 34. -44.9599999999999937 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 311.75 Td +(I1.2) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 323.2100000000000364 145. -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 311.75 Td +(Import Operations > Arrival Queue.) Tj +T* (Open the arrived train, assign) Tj +T* (warehouse/yard/zone per booking, Auto) Tj +T* (Unload Arrived Bookings.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 323.2100000000000364 234.2800000000000296 -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 311.75 Td +(Inventory created at UNLOADED. Counter shows n/total unloaded.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 323.2100000000000364 98. -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 311.75 Td +(Train must be ARRIVED) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 278.25 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 266.7899999999999636 Td +(I1.3) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 278.25 145. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 266.7899999999999636 Td +(Inspect the item, outcome PASSED.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 278.25 234.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 266.7899999999999636 Td +(Item advances to READY_FOR_PICKUP. Customer receives) Tj +T* ("Assign a truck for pickup" \(in-app + SMS + email\).) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 278.25 98. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 266.7899999999999636 Td +(Fires only when self-haul) Tj +T* (AND no truck assigned) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 250.7699999999999818 34. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 239.3099999999999454 Td +(I1.4) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 250.7699999999999818 145. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 239.3099999999999454 Td +(PORTAL: customer assigns truck\(s\),) Tj +T* (entering ISO container numbers per) Tj +T* (truck.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 250.7699999999999818 234.2800000000000296 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 239.3099999999999454 Td +(Containers move to stage ASSIGNED and show their planned) Tj +T* (truck. Booking becomes TRUCK_ASSIGNED.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 250.7699999999999818 98. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 239.3099999999999454 Td +(20ft -> max 2/truck; 40ft ->) Tj +T* (1/truck; trucks <=) Tj +T* (containers) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 214.5499999999999545 34. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 203.0899999999999181 Td +(I1.5) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 214.5499999999999545 145. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 203.0899999999999181 Td +(Backoffice row menu > Truck Arrival.) Tj +T* (Select the assigned truck, record gate-in) Tj +T* (time and TARE weight \(tonnes\).) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 214.5499999999999545 234.2800000000000296 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 203.0899999999999181 Td +(Truck stamped arrived. A SELF_HAUL handover is generated) Tj +T* (\(booking-level\) and the customer is notified to sign, on all three) Tj +T* (channels.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 214.5499999999999545 98. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 203.0899999999999181 Td +(Truck Arrival disabled until) Tj +T* (a truck is assigned) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 178.3299999999999272 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 166.8699999999998909 Td +(I1.6) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 178.3299999999999272 145. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 166.8699999999998909 Td +(PORTAL: customer opens the booking >) Tj +T* (Approve delivery.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 178.3299999999999272 234.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 166.8699999999998909 Td +(The handover PDF opens for review; approving applies their saved) Tj +T* (signature and returns the signed PDF.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 178.3299999999999272 98. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 166.8699999999998909 Td +(Signs all unsigned) Tj +T* (handovers on the booking) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 150.8499999999999091 34. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 139.3899999999998727 Td +(I1.7) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 150.8499999999999091 145. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 139.3899999999998727 Td +(Open the container list > select the) Tj +T* (assigned containers > Load onto truck) Tj +T* (\(pick the arrived truck\).) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 150.8499999999999091 234.2800000000000296 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 139.3899999999998727 Td +(Containers move to LOADED; loaded_at stamped.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 150.8499999999999091 98. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 139.3899999999998727 Td +(Only arrived, not-departed) Tj +T* (trucks are listed) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 114.6299999999998818 34. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 103.1699999999998454 Td +(I1.8) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 114.6299999999998818 145. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 103.1699999999998454 Td +(Row menu > Truck Leaving. Select the) Tj +T* (containers on the truck, record gate-out) Tj +T* (time and GROSS weight.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 114.6299999999998818 234.2800000000000296 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 103.1699999999998454 Td +(Net is computed from the selected containers' cargo weight and) Tj +T* (must equal gross - tare. Release document issued.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 114.6299999999998818 98. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 103.1699999999998454 Td +(Weight match enforced) Tj +T* (client- AND server-side) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 78.4099999999998545 34. -18.7399999999999984 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 66.9499999999998181 Td +(I1.9) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 78.4099999999998545 145. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 66.9499999999998181 Td +(Generate the exit paper for the truck.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 78.4099999999998545 234.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 66.9499999999998181 Td +(PDF lists the truck, driver, and its containers. Containers move to) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 78.4099999999998545 98. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 66.9499999999998181 Td +(Requires signed handover) Tj +ET +0.8 0.85 0.85 RG +0.5 w +0.8 0.85 0.85 RG +0.5 w +0.78 G +0. w +0.8 0.85 0.85 RG +0.5 w +42. 36. m +553.2799999999999727 36. l +S +BT +/F9 8 Tf +9.1999999999999993 TL +0.427 0.498 0.51 rg +42. 22. Td +(EDR Freight - QA Test Plan) Tj +ET +BT +/F5 7.5 Tf +8.625 TL +0.427 0.498 0.51 rg +530.7799999999999727 22. Td +(3 / 7) Tj +ET +endstream +endobj +9 0 obj +<> +endobj +10 0 obj +<< +/Length 20063 +>> +stream +0. w +0.78 G +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 801.8899999999999864 34. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 790.9399999999999409 Td +(Step) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +76. 801.8899999999999864 145. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +81. 790.9399999999999409 Td +(Tester action) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +221. 801.8899999999999864 234.2800000000000296 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +226. 790.9399999999999409 Td +(Expected result) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +455.2800000000000296 801.8899999999999864 98. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +460.2800000000000296 790.9399999999999409 Td +(Technical ref) Tj +ET +0.8 0.85 0.85 RG +0.5 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 783.8400000000000318 34. -18.7399999999999984 re +B +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 783.8400000000000318 145. -18.7399999999999984 re +B +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 783.8400000000000318 234.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 772.3799999999999955 Td +(LEFT.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 783.8400000000000318 98. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 772.3799999999999955 Td +(+ cleared fees) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 765.1000000000000227 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 753.6399999999999864 Td +(I1.10) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 765.1000000000000227 145. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 753.6399999999999864 Td +(Deliver the inventory, recording the) Tj +T* (receiver name.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 765.1000000000000227 234.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 753.6399999999999864 Td +(Status DELIVERED; handovers stamped delivered.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2800000000000296 765.1000000000000227 98. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2800000000000296 753.6399999999999864 Td +(Requires handover signed) Tj +T* (AND truck departed) Tj +ET +0.8 0.85 0.85 RG +0.5 w +0.78 G +0. w +0.8 0.85 0.85 RG +0.5 w +0.8 0.85 0.85 RG +0.5 w +0.97 0.93 0.93 rg +42. 723.6200000000000045 511.2799999999999727 -51. re +f +0.62 0.17 0.2 rg +42. 723.6200000000000045 3. -51. re +f +BT +/F1 8.6 Tf +9.8899999999999988 TL +0.275 0.345 0.357 rg +56. 710.6200000000000045 Td +(THE SINGLE MOST LIKELY BUG YOU WILL HIT. Exit paper returns 400 when the handover is not fully signed, or when a) Tj +T* (warehouse fee invoice is ISSUED / PARTIALLY_PAID. In the container list, the Exit Paper button turns grey and clicking it sends) Tj +T* (the customer a signature request instead of erroring. That is correct behaviour - verify the message, do not file it as a bug.) Tj +ET +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 650.6200000000000045 Td +(05) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 649.6200000000000045 Td +(Import - EDR last mile) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 629.6200000000000045 m +553.2799999999999727 629.6200000000000045 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 615.6200000000000045 Td +(EDR delivers to the customer's door. No customer truck, no portal Approve delivery, and the handover is PER DELIVERING) Tj +T* (TRUCK - not per booking. This is where truck detention accrues.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 581.6200000000000045 34. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 570.6700000000000728 Td +(Step) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +76. 581.6200000000000045 145. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +81. 570.6700000000000728 Td +(Tester action) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +221. 581.6200000000000045 234.2799999999999727 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +226. 570.6700000000000728 Td +(Expected result) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +455.2799999999999727 581.6200000000000045 98. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +460.2799999999999727 570.6700000000000728 Td +(Technical ref) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 563.5699999999999363 34. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 552.1100000000000136 Td +(I2.1) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 563.5699999999999363 145. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 552.1100000000000136 Td +(Import booking with a last-mile delivery) Tj +T* (address \(or service type including last) Tj +T* (mile\).) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 563.5699999999999363 234.2799999999999727 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 552.1100000000000136 Td +(hasLastMile is true; no "assign a truck" notification is sent.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 563.5699999999999363 98. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 552.1100000000000136 Td +(EDR haulage - customer) Tj +T* (assigns nothing) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 527.3500000000000227 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 515.8900000000001 Td +(I2.2) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 527.3500000000000227 145. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 515.8900000000001 Td +(Unload from the arrived train, inspect) Tj +T* (PASSED.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 527.3500000000000227 234.2799999999999727 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 515.8900000000001 Td +(Item becomes READY_FOR_PICKUP and the last-mile leg is) Tj +T* (accepted automatically.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 527.3500000000000227 98. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 515.8900000000001 Td +() Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 499.8700000000000045 34. -18.7399999999999984 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 488.410000000000025 Td +(I2.3) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 499.8700000000000045 145. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 488.410000000000025 Td +(Last Mile page: assign vehicle + driver.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 499.8700000000000045 234.2799999999999727 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 488.410000000000025 Td +(Leg reaches READY_TO_TRANSIT. Row shows Assigned.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 499.8700000000000045 98. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 488.410000000000025 Td +(Driver notified by SMS) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 481.1299999999999955 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 469.6700000000000159 Td +(I2.4) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 481.1299999999999955 145. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 469.6700000000000159 Td +(Truck Arrival from the Last Mile row:) Tj +T* (gate-in, tare weight.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 481.1299999999999955 234.2799999999999727 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 469.6700000000000159 Td +(Weighing saved; the assigned last-mile truck is pre-selected.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 481.1299999999999955 98. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 469.6700000000000159 Td +(Re-opening must show) Tj +T* (the saved details) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 453.6499999999999773 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 442.1899999999999977 Td +(I2.5) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 453.6499999999999773 145. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 442.1899999999999977 Td +(Truck Leaving: gate-out, gross weight.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 453.6499999999999773 234.2799999999999727 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 442.1899999999999977 Td +(Release document issued; leg moves to IN_TRANSIT. arrivedAt) Tj +T* (starts the detention clock.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 453.6499999999999773 98. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 442.1899999999999977 Td +(Detention window opens) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 426.1699999999999591 34. -36.2199999999999989 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 414.7099999999999795 Td +(I2.6) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 426.1699999999999591 145. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 414.7099999999999795 Td +(Deliver at the customer's door, recording) Tj +T* (the receiver name.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 426.1699999999999591 234.2799999999999727 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 414.7099999999999795 Td +(Item DELIVERED. An EDR_LAST_MILE handover is generated) Tj +T* (PER DELIVERING TRUCK, resolved from the container's allocated) Tj +T* (vehicle.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 426.1699999999999591 98. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 414.7099999999999795 Td +(Not booking-level) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 389.9499999999999318 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 378.4899999999999523 Td +(I2.7) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 389.9499999999999318 145. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 378.4899999999999523 Td +(Return the vehicle > mark the leg) Tj +T* (DELIVERED.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 389.9499999999999318 234.2799999999999727 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 378.4899999999999523 Td +(deliveredAt stamped; detention clock stops.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 389.9499999999999318 98. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 378.4899999999999523 Td +() Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 362.4699999999999136 34. -27.4799999999999969 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 351.0099999999999341 Td +(I2.8) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 362.4699999999999136 145. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 351.0099999999999341 Td +(Preview, then generate the truck) Tj +T* (detention invoice.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 362.4699999999999136 234.2799999999999727 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 351.0099999999999341 Td +(Charged per truck per day beyond the grace hours, at the matching) Tj +T* (tier. See section 07.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 362.4699999999999136 98. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 351.0099999999999341 Td +(POST /last-mile/:id/genera) Tj +T* (te-truck-detention-invoice) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 334.9899999999998954 34. -36.2199999999999989 re +B +BT +/F6 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 323.529999999999859 Td +(I2.G) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +76. 334.9899999999998954 145. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +81. 323.529999999999859 Td +(Open the booking in the portal.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +221. 334.9899999999998954 234.2799999999999727 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +226. 323.529999999999859 Td +(ABSENT: Approve delivery must NOT appear. The portal flag) Tj +T* (counts only SELF_HAUL handovers; EDR handovers are signed) Tj +T* (by the receiver at the door.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +455.2799999999999727 334.9899999999998954 98. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +460.2799999999999727 323.529999999999859 Td +(Regression check) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 272.7699999999998681 Td +(06) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 271.7699999999998681 Td +(Marshalling, gate pass, interchange) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 251.7699999999998681 m +553.2799999999999727 251.7699999999998681 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 237.7699999999998681 Td +(Documents are generated, not uploaded. Each has a precondition; test the precondition, not just the PDF.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 215.7699999999998681 92. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 204.8199999999998226 Td +(Document) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +134. 215.7699999999998681 95. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +139. 204.8199999999998226 Td +(When) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +229. 215.7699999999998681 146.1434567681443468 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +234. 204.8199999999998226 Td +(Precondition) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +375.1434567681443468 215.7699999999998681 178.1365432318555975 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +380.1434567681443468 204.8199999999998226 Td +(Verify) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 197.7199999999999136 92. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 186.2599999999998772 Td +(GRN) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +134. 197.7199999999999136 95. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +139. 186.2599999999998772 Td +(On receive to warehouse) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +229. 197.7199999999999136 146.1434567681443468 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +234. 186.2599999999998772 Td +(Booking PAID; route direction matches;) Tj +T* (export needs a truck entrance) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +375.1434567681443468 197.7199999999999136 178.1365432318555975 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +380.1434567681443468 186.2599999999998772 Td +(Number format GRN---<8>;) Tj +T* (PDF opens; customer SMS sent) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 170.2399999999998954 92. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 158.779999999999859 Td +(Import load list /) Tj +T* (marshalling) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +134. 170.2399999999998954 95. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +139. 158.779999999999859 Td +(Import train, before) Tj +T* (unload) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +229. 170.2399999999998954 146.1434567681443468 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +234. 158.779999999999859 Td +(Schedule has bookings assigned) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +375.1434567681443468 170.2399999999998954 178.1365432318555975 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +380.1434567681443468 158.779999999999859 Td +(Summary endpoint and printable PDF agree;) Tj +T* (portrait/landscape both render) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 142.7599999999998772 92. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 131.2999999999998408 Td +(Export marshalling /) Tj +T* (load list) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +134. 142.7599999999998772 95. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +139. 131.2999999999998408 Td +(Export train, after loading) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +229. 142.7599999999998772 146.1434567681443468 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +234. 131.2999999999998408 Td +(Items LOADED onto wagons) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +375.1434567681443468 142.7599999999998772 178.1365432318555975 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +380.1434567681443468 131.2999999999998408 Td +(Wagon, booking, container rows are complete) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 115.279999999999859 92. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 103.8199999999998226 Td +(Gate pass) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +134. 115.279999999999859 95. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +139. 103.8199999999998226 Td +(Djibouti-side operations) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +229. 115.279999999999859 146.1434567681443468 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +234. 103.8199999999998226 Td +(Granted per schedule) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +375.1434567681443468 115.279999999999859 178.1365432318555975 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +380.1434567681443468 103.8199999999998226 Td +(gatepass_granted_at is set; export Djibouti unload) Tj +T* (reads this same field) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 87.7999999999998408 92. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 76.3399999999998045 Td +(Interchange document) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +134. 87.7999999999998408 95. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +139. 76.3399999999998045 Td +(Automatically, after a) Tj +T* (successful Djibouti) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +229. 87.7999999999998408 146.1434567681443468 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +234. 76.3399999999998045 Td +(At least one item unloaded) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +375.1434567681443468 87.7999999999998408 178.1365432318555975 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +380.1434567681443468 76.3399999999998045 Td +(Document number returned in the unload) Tj +T* (response; visible in Interchange Documents) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.8 0.85 0.85 RG +0.5 w +42. 36. m +553.2799999999999727 36. l +S +BT +/F9 8 Tf +9.1999999999999993 TL +0.427 0.498 0.51 rg +42. 22. Td +(EDR Freight - QA Test Plan) Tj +ET +BT +/F5 7.5 Tf +8.625 TL +0.427 0.498 0.51 rg +530.7799999999999727 22. Td +(4 / 7) Tj +ET +endstream +endobj +11 0 obj +<> +endobj +12 0 obj +<< +/Length 13441 +>> +stream +0. w +0.78 G +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 801.8899999999999864 92. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 790.9399999999999409 Td +(Document) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +134. 801.8899999999999864 95. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +139. 790.9399999999999409 Td +(When) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +229. 801.8899999999999864 146.1434567681443468 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +234. 790.9399999999999409 Td +(Precondition) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +375.1434567681443468 801.8899999999999864 178.1365432318555975 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +380.1434567681443468 790.9399999999999409 Td +(Verify) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 783.8400000000000318 92. -18.7399999999999984 re +B +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +134. 783.8400000000000318 95. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +139. 772.3799999999999955 Td +(unload) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +229. 783.8400000000000318 146.1434567681443468 -18.7399999999999984 re +B +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +375.1434567681443468 783.8400000000000318 178.1365432318555975 -18.7399999999999984 re +B +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 765.1000000000000227 92. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 753.6399999999999864 Td +(Handover) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +134. 765.1000000000000227 95. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +139. 753.6399999999999864 Td +(Self-haul: on truck arrival.) Tj +T* (EDR: at delivery.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +229. 765.1000000000000227 146.1434567681443468 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +234. 753.6399999999999864 Td +(See sections 04 / 05) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +375.1434567681443468 765.1000000000000227 178.1365432318555975 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +380.1434567681443468 753.6399999999999864 Td +(Self-haul is booking-level, one per booking; EDR) Tj +T* (is one per delivering truck) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 737.6200000000000045 92. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 726.1599999999999682 Td +(Exit paper / release doc) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +134. 737.6200000000000045 95. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +139. 726.1599999999999682 Td +(Truck leaving) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +229. 737.6200000000000045 146.1434567681443468 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +234. 726.1599999999999682 Td +(Handover fully signed AND no unpaid) Tj +T* (warehouse fee) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +375.1434567681443468 737.6200000000000045 178.1365432318555975 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +380.1434567681443468 726.1599999999999682 Td +(Weights on the paper match the gate weighing) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.97 0.94 0.89 rg +42. 696.1399999999999864 511.2799999999999727 -51. re +f +0.54 0.35 0.04 rg +42. 696.1399999999999864 3. -51. re +f +BT +/F1 8.6 Tf +9.8899999999999988 TL +0.275 0.345 0.357 rg +56. 683.1399999999999864 Td +(CROSS-MODULE QUIRK WORTH CONFIRMING WITH THE TEAM. The EXPORT Djibouti unload checks the gate-pass flag) Tj +T* (stored on the IMPORT Djibouti operations record \(import_djibouti_operations.gatepass_granted_at\). It works, but it is surprising.) Tj +T* (If an export unload silently skips every item, check that field first.) Tj +ET +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 623.1399999999999864 Td +(07) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 622.1399999999999864 Td +(Fee rules) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 602.1399999999999864 m +553.2799999999999727 602.1399999999999864 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 588.1399999999999864 Td +(Four rule types. Two are day-based with free days and tiers; double handling is a flat rate multiplied by a basis; truck detention is) Tj +T* (hour-graced and vehicle-scoped.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 554.1399999999999864 88. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 543.1900000000000546 Td +(Rule type) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +130. 554.1399999999999864 72. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +135. 543.1900000000000546 Td +(Charged on) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +202. 554.1399999999999864 85. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +207. 543.1900000000000546 Td +(Key fields) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +287. 554.1399999999999864 266.2799999999999727 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +292. 543.1900000000000546 Td +(Test cases) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 536.0899999999999181 88. -27.4799999999999969 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 525.1399999999999864 Td +(STORAGE_FEE) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +130. 536.0899999999999181 72. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +135. 524.6299999999999955 Td +(Days in storage) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +202. 536.0899999999999181 85. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +207. 524.6299999999999955 Td +(free days, tiers) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +287. 536.0899999999999181 266.2799999999999727 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +292. 524.6299999999999955 Td +(Within free days -> zero. One day past -> tier 1. Cross a tier boundary ->) Tj +T* (correct tier rate.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 508.6099999999999568 88. -27.4799999999999969 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 497.6599999999999682 Td +(DEMURRAGE_FEE) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +130. 508.6099999999999568 72. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +135. 497.1499999999999773 Td +(Days beyond free) Tj +T* (time) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +202. 508.6099999999999568 85. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +207. 497.1499999999999773 Td +(free days, tiers) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +287. 508.6099999999999568 266.2799999999999727 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +292. 497.1499999999999773 Td +(Same boundary tests. Confirm it blocks exit paper and delivery while) Tj +T* (ISSUED.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 481.1299999999999386 88. -44.9599999999999937 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 470.17999999999995 Td +(DOUBLE_HANDLING_FE) Tj +T* (E) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +130. 481.1299999999999386 72. -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +135. 469.6699999999999591 Td +(Flat rate x quantity) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +202. 481.1299999999999386 85. -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +207. 469.6699999999999591 Td +(basis:) Tj +T* (PER_CONTAINER |) Tj +T* (PER_TON |) Tj +T* (PER_ITEM) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +287. 481.1299999999999386 266.2799999999999727 -44.9599999999999937 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +292. 469.6699999999999591 Td +(Container booking -> PER_CONTAINER uses container count. Bulk ->) Tj +T* (PER_TON uses tonnage. Break-bulk machinery -> PER_ITEM uses item) Tj +T* (count. IMPORT ONLY. Free days and tiers must not apply.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 436.1699999999999591 88. -36.2199999999999989 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 425.2199999999999704 Td +(TRUCK_DETENTION_FE) Tj +T* (E) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +130. 436.1699999999999591 72. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +135. 424.7099999999999795 Td +(Per truck, per day) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +202. 436.1699999999999591 85. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +207. 424.7099999999999795 Td +(free_hours grace,) Tj +T* (tiers, vehicle type) Tj +T* (scope) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +287. 436.1699999999999591 266.2799999999999727 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +292. 424.7099999999999795 Td +(Return inside the grace window -> zero. Just past grace -> day 1 at tier 1.) Tj +T* (Multi-day -> tier escalation. A vehicle type outside the rule's scope -> no) Tj +T* (charge. IMPORT ONLY.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +BT +/F2 8 Tf +9.1999999999999993 TL +0.063 0.102 0.11 rg +42. 377.9499999999999318 Td +(FEE BEHAVIOUR TO VERIFY ON EVERY RULE) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 373.4499999999999318 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 365.9499999999999318 Td +(Preview before invoice. The preview amount must equal the issued invoice total.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 356.4499999999999318 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 348.9499999999999318 Td +(Notification on issue. Issuing a warehouse fee invoice sends the customer an in-app INVOICE_ISSUED notification AND) Tj +T* (an SMS, deep-linked to pay.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 327.4499999999999318 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 319.9499999999999318 Td +(Clearance gate. While a warehouse-source invoice is ISSUED or PARTIALLY_PAID, exit paper, terminal release, and) Tj +T* (Approve delivery are all blocked.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 298.4499999999999318 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 290.9499999999999318 Td +(Payable-but-uninvoiced. If fees are payable and no invoice exists yet, release is still blocked with "Generate and fully) Tj +T* (pay...". Confirm the operator can generate it from that state.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 269.4499999999999318 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 261.9499999999999318 Td +(Fully paid -> release proceeds; a receipt PDF is available.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 252.4499999999999318 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 244.9499999999999318 Td +(Edit a rule \(rate, free days, tiers, grace hours\) and confirm the next preview reflects it.) Tj +ET +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 211.9499999999999318 Td +(08) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 210.9499999999999318 Td +(Allocation rules) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 190.9499999999999318 m +553.2799999999999727 190.9499999999999318 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 176.9499999999999318 Td +(Where an item is stored is decided by the first matching rule, in priority order. Test the precedence, not just one rule.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 154.9499999999999318 258.0206829268292381 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 143.9999999999998863 Td +(Match criteria \(any may be null = wildcard\)) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +300.0206829268292381 154.9499999999999318 253.2593170731707346 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +305.0206829268292381 143.9999999999998863 Td +(Targets) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 136.8999999999999773 258.0206829268292381 -34.1499999999999986 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.063 0.102 0.11 rg +47. 125.9499999999999318 Td +(freight_type, trade_direction, cargo_type_code,) Tj +T* (container_status, requires_inspection, ordered by priority) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +300.0206829268292381 136.8999999999999773 253.2593170731707346 -34.1499999999999986 re +B +BT +/F5 7 Tf +8.0499999999999989 TL +0.275 0.345 0.357 rg +305.0206829268292381 125.9499999999999318 Td +(target_facility_code, target_warehouse_code,) Tj +T* (target_yard_code \(required\), target_zone_code,) Tj +T* (storage_type) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +BT +/F2 8 Tf +9.1999999999999993 TL +0.063 0.102 0.11 rg +42. 80.75 Td +(PRECEDENCE, HIGHEST FIRST) Tj +ET +0.8 0.85 0.85 RG +0.5 w +42. 36. m +553.2799999999999727 36. l +S +BT +/F9 8 Tf +9.1999999999999993 TL +0.427 0.498 0.51 rg +42. 22. Td +(EDR Freight - QA Test Plan) Tj +ET +BT +/F5 7.5 Tf +8.625 TL +0.427 0.498 0.51 rg +530.7799999999999727 22. Td +(5 / 7) Tj +ET +endstream +endobj +13 0 obj +<> +endobj +14 0 obj +<< +/Length 21215 +>> +stream +0.9 w +0.06 0.1 0.11 RG +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 793.8899999999999864 20. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 782.9399999999999409 Td +(#) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +62. 793.8899999999999864 120. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +67. 782.9399999999999409 Td +(Source of the location) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +182. 793.8899999999999864 207.6664663121432 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +187. 782.9399999999999409 Td +(How to trigger) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +389.6664663121432 793.8899999999999864 163.6135336878567728 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +394.6664663121432 782.9399999999999409 Td +(Note recorded) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 775.8400000000000318 20. -18.7399999999999984 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 764.3799999999999955 Td +(1) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +62. 775.8400000000000318 120. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +67. 764.3799999999999955 Td +(Operator selection) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +182. 775.8400000000000318 207.6664663121432 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +187. 764.3799999999999955 Td +(Store modal > pick warehouse + yard + zone) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +389.6664663121432 775.8400000000000318 163.6135336878567728 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +394.6664663121432 764.3799999999999955 Td +("Stored at operator-selected location") Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 757.1000000000000227 20. -18.7399999999999984 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 745.6399999999999864 Td +(2) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +62. 757.1000000000000227 120. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +67. 745.6399999999999864 Td +(Allocation rule) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +182. 757.1000000000000227 207.6664663121432 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +187. 745.6399999999999864 Td +(Leave the Store modal blank; a matching rule exists) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +389.6664663121432 757.1000000000000227 163.6135336878567728 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +394.6664663121432 745.6399999999999864 Td +("Stored by allocation rule ") Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 738.3600000000000136 20. -18.7399999999999984 re +B +BT +/F5 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 726.8999999999999773 Td +(3) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +62. 738.3600000000000136 120. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +67. 726.8999999999999773 Td +(Capacity-balanced fallback) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +182. 738.3600000000000136 207.6664663121432 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +187. 726.8999999999999773 Td +(Leave blank; no rule matches) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +389.6664663121432 738.3600000000000136 163.6135336878567728 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +394.6664663121432 726.8999999999999773 Td +("Stored by capacity-balanced allocation") Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.43 0.5 0.51 RG +0.6 w +43. 711.1200000000000045 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 703.6200000000000045 Td +(Two matching rules -> the LOWER priority number wins.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 694.1200000000000045 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 686.6200000000000045 Td +(Yard dropdowns are filtered by freight type - container bookings offer container yards only.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 677.1200000000000045 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 669.6200000000000045 Td +(Only ACTIVE warehouses, yards and zones are selectable.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 660.1200000000000045 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 652.6200000000000045 Td +(Storing beyond a zone's capacity is rejected; capacities are compared in TONNES.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 643.1200000000000045 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 635.6200000000000045 Td +(Move an item to another warehouse/yard/zone -> capacity released at source, taken at destination.) Tj +ET +0.43 0.5 0.51 RG +0.6 w +43. 626.1200000000000045 8. -8. re +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +60. 618.6200000000000045 Td +(Edit a rule, an existing warehouse, a yard, and a zone - all four must be editable.) Tj +ET +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 585.6200000000000045 Td +(09) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 584.6200000000000045 Td +(Negative & guard cases) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 564.6200000000000045 m +553.2799999999999727 564.6200000000000045 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 550.6200000000000045 Td +(Every row here is intended behaviour. The test passes when the action is REFUSED with the stated message. Anything that) Tj +T* (succeeds is the bug.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 516.6200000000000045 74. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 505.6700000000000159 Td +(Area) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +116. 516.6200000000000045 195. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +121. 505.6700000000000159 Td +(Attempt) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +311. 516.6200000000000045 242.2800000000000296 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +316. 505.6700000000000159 Td +(Expected refusal) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 498.5699999999999932 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 487.1100000000000136 Td +(Booking) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 498.5699999999999932 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 487.1100000000000136 Td +(Enter a container number that is not 4 letters + 7 digits) Tj +T* (\(e.g. MSKU10105185, 3456789\).) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 498.5699999999999932 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 487.1100000000000136 Td +("Use ISO format: 4 letters + 7 digits". Lowercase is auto-uppercased;) Tj +T* (input capped at 11 characters.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 471.089999999999975 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 459.6299999999999955 Td +(Receive) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 471.089999999999975 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 459.6299999999999955 Td +(Receive a booking that is not PAID.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 471.089999999999975 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 459.6299999999999955 Td +(Skipped: "Booking not PAID".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 452.3499999999999659 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 440.8899999999999864 Td +(Receive) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 452.3499999999999659 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 440.8899999999999864 Td +(Receive the same booking twice.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 452.3499999999999659 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 440.8899999999999864 Td +(Skipped: "Already received".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 433.6099999999999568 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 422.1499999999999773 Td +(Receive) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 433.6099999999999568 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 422.1499999999999773 Td +(Export receive with no truck entrance captured.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 433.6099999999999568 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 422.1499999999999773 Td +(Rejected before any inventory is created.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 414.8699999999999477 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 403.4099999999999682 Td +(Truck assign) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 414.8699999999999477 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 403.4099999999999682 Td +(Put two 40ft containers on one truck.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 414.8699999999999477 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 403.4099999999999682 Td +("A 40ft container fills the truck - assign only 1 container to this truck".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 396.1299999999999386 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 384.6699999999999591 Td +(Truck assign) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 396.1299999999999386 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 384.6699999999999591 Td +(Put three containers on one truck.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 396.1299999999999386 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 384.6699999999999591 Td +("A truck carries at most 2 containers".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 377.3899999999999295 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 365.92999999999995 Td +(Truck assign) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 377.3899999999999295 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 365.92999999999995 Td +(Assign more trucks than the booking has containers.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 377.3899999999999295 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 365.92999999999995 Td +("Cannot assign more trucks than containers...".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 358.6499999999999204 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 347.1899999999999409 Td +(Truck assign) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 358.6499999999999204 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 347.1899999999999409 Td +(Assign a container from another booking, or one) Tj +T* (already on another truck.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 358.6499999999999204 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 347.1899999999999409 Td +("...is not one of this booking's containers" / "...already loaded onto) Tj +T* (another truck".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 331.1699999999999022 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 319.7099999999999227 Td +(Truck assign) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 331.1699999999999022 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 319.7099999999999227 Td +(Edit a truck AFTER it has arrived.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 331.1699999999999022 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 319.7099999999999227 Td +(Refused - edits are allowed only until arrival.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 312.42999999999995 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 300.9699999999999136 Td +(Loading) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 312.42999999999995 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 300.9699999999999136 Td +(Load containers onto a truck that has not arrived.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 312.42999999999995 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 300.9699999999999136 Td +("Record the truck arrival before loading...". The truck picker lists only) Tj +T* (arrived, not-departed trucks.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 284.9499999999999318 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 273.4899999999998954 Td +(Loading) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 284.9499999999999318 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 273.4899999999998954 Td +(Load onto a truck that has already departed.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 284.9499999999999318 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 273.4899999999998954 Td +("This truck has already left - its load is locked".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 266.2099999999999227 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 254.7499999999998863 Td +(Stages) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 266.2099999999999227 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 254.7499999999998863 Td +(Customer assigns containers to a truck, then check the) Tj +T* (container list.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 266.2099999999999227 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 254.7499999999998863 Td +(Stage is ASSIGNED, NEVER LOADED. Exit Paper is not offered.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 238.7299999999999045 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 227.2699999999998681 Td +(Truck leaving) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 238.7299999999999045 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 227.2699999999998681 Td +(Enter a gross weight where gross - tare != the selected) Tj +T* (containers' cargo weight.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 238.7299999999999045 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 227.2699999999998681 Td +("Weight mismatch...". Exit paper and gate clearance blocked, client) Tj +T* (and server.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 211.2499999999998863 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 199.7899999999998499 Td +(Truck leaving) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 211.2499999999998863 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 199.7899999999998499 Td +(Save leaving with no containers selected.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 211.2499999999998863 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 199.7899999999998499 Td +("Select the containers loaded on this truck".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 192.5099999999998772 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 181.0499999999998408 Td +(Exit paper) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 192.5099999999998772 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 181.0499999999998408 Td +(Generate before the handover is signed.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 192.5099999999998772 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 181.0499999999998408 Td +("Handover must be signed...". In the container list the button is grey) Tj +T* (and instead SENDS THE CUSTOMER A SIGNATURE REQUEST.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 165.029999999999859 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 153.5699999999998226 Td +(Exit paper) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 165.029999999999859 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 153.5699999999998226 Td +(Generate with an ISSUED demurrage/storage invoice.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 165.029999999999859 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 153.5699999999998226 Td +("...must be fully paid before terminal release".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 146.2899999999998499 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 134.8299999999998136 Td +(Approve delivery) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 146.2899999999998499 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 134.8299999999998136 Td +(Approve without a saved signature.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 146.2899999999998499 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 134.8299999999998136 Td +("Please save your signature..." and the portal routes to the signature) Tj +T* (page.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 118.8099999999998317 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 107.3499999999997954 Td +(Approve delivery) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 118.8099999999998317 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 107.3499999999997954 Td +(Approve before warehouse inspection has passed.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 118.8099999999998317 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 107.3499999999997954 Td +("Delivery can be approved after warehouse inspection has passed".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 100.0699999999998226 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 88.6099999999997863 Td +(Approve delivery) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 100.0699999999998226 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 88.6099999999997863 Td +(Approve when a truck is assigned but has not arrived.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 100.0699999999998226 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 88.6099999999997863 Td +("Customer truck arrival must be recorded before delivery approval".) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 81.3299999999998136 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 69.8699999999997772 Td +(Deliver) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 81.3299999999998136 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 69.8699999999997772 Td +(Deliver before a release order was issued, or before) Tj +T* (the self-haul truck has left.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 81.3299999999998136 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 69.8699999999997772 Td +("A release order must be issued..." / "Deliver is available only after) Tj +T* (the customer truck has left".) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.8 0.85 0.85 RG +0.5 w +42. 36. m +553.2799999999999727 36. l +S +BT +/F9 8 Tf +9.1999999999999993 TL +0.427 0.498 0.51 rg +42. 22. Td +(EDR Freight - QA Test Plan) Tj +ET +BT +/F5 7.5 Tf +8.625 TL +0.427 0.498 0.51 rg +530.7799999999999727 22. Td +(6 / 7) Tj +ET +endstream +endobj +15 0 obj +<> +endobj +16 0 obj +<< +/Length 15025 +>> +stream +0. w +0.78 G +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 801.8899999999999864 74. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 790.9399999999999409 Td +(Area) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +116. 801.8899999999999864 195. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +121. 790.9399999999999409 Td +(Attempt) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +311. 801.8899999999999864 242.2800000000000296 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +316. 790.9399999999999409 Td +(Expected refusal) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 783.8400000000000318 74. -18.7399999999999984 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 772.3799999999999955 Td +(Djibouti unload) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 783.8400000000000318 195. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 772.3799999999999955 Td +(Unload an export train with no gate pass granted.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 783.8400000000000318 242.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 772.3799999999999955 Td +(Items skipped; no interchange document generated.) Tj +ET +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 765.1000000000000227 74. -27.4799999999999969 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 753.6399999999999864 Td +(Warehouse) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +116. 765.1000000000000227 195. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +121. 753.6399999999999864 Td +(Store into an INACTIVE warehouse/yard/zone, or) Tj +T* (beyond capacity.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +311. 765.1000000000000227 242.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +316. 753.6399999999999864 Td +(Not selectable / "No active warehouse yard/zone is available" /) Tj +T* (capacity error.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 711.6200000000000045 Td +(10) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 710.6200000000000045 Td +(Notifications) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 690.6200000000000045 m +553.2799999999999727 690.6200000000000045 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 676.6200000000000045 Td +(All customer notifications land in the same portal inbox. Verify the message, the deep link, and - where noted - the SMS and) Tj +T* (email.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 642.6200000000000045 96. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 631.6700000000000728 Td +(Notification) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +138. 642.6200000000000045 257.2800000000000296 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +143. 631.6700000000000728 Td +(Fires when) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +395.2800000000000296 642.6200000000000045 74. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +400.2800000000000296 631.6700000000000728 Td +(Channels) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +469.2800000000000296 642.6200000000000045 84. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +474.2800000000000296 631.6700000000000728 Td +(Deep link) Tj +ET +0.06 0.1 0.11 RG +0.9 w +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 624.5699999999999363 96. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 613.1100000000000136 Td +(Shipment dispatched) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +138. 624.5699999999999363 257.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +143. 613.1100000000000136 Td +(Train schedule dispatched, per booking) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +395.2800000000000296 624.5699999999999363 74. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +400.2800000000000296 613.1100000000000136 Td +(In-app, SMS, email) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +469.2800000000000296 624.5699999999999363 84. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +474.2800000000000296 613.1100000000000136 Td +(Booking) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 605.8299999999999272 96. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 594.3700000000000045 Td +(Shipment arrived) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +138. 605.8299999999999272 257.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +143. 594.3700000000000045 Td +(Train schedule arrived, per booking) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +395.2800000000000296 605.8299999999999272 74. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +400.2800000000000296 594.3700000000000045 Td +(In-app, SMS, email) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +469.2800000000000296 605.8299999999999272 84. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +474.2800000000000296 594.3700000000000045 Td +(Booking) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 587.0899999999999181 96. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 575.6299999999999955 Td +(Assign a truck for pickup) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +138. 587.0899999999999181 257.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +143. 575.6299999999999955 Td +(Export: on warehouse receive. Import: on inspection pass -> ready for) Tj +T* (pickup. Only if self-haul AND no truck assigned.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +395.2800000000000296 587.0899999999999181 74. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +400.2800000000000296 575.6299999999999955 Td +(In-app, SMS, email) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +469.2800000000000296 587.0899999999999181 84. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +474.2800000000000296 575.6299999999999955 Td +(Booking > assign) Tj +T* (truck) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 559.6099999999999 96. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 548.1499999999999773 Td +(Handover - signature) Tj +T* (needed) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +138. 559.6099999999999 257.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +143. 548.1499999999999773 Td +(Self-haul truck arrives \(handover generated\), and re-sent when an) Tj +T* (operator requests a signature from the Exit Paper button.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +395.2800000000000296 559.6099999999999 74. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +400.2800000000000296 548.1499999999999773 Td +(In-app, SMS, email) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +469.2800000000000296 559.6099999999999 84. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +474.2800000000000296 548.1499999999999773 Td +(Booking > approve) Tj +T* (delivery) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 532.1299999999998818 96. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 520.6699999999999591 Td +(Warehouse fee due) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +138. 532.1299999999998818 257.2800000000000296 -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +143. 520.6699999999999591 Td +(Storage / demurrage invoice issued) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +395.2800000000000296 532.1299999999998818 74. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +400.2800000000000296 520.6699999999999591 Td +(In-app, SMS) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +469.2800000000000296 532.1299999999998818 84. -18.7399999999999984 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +474.2800000000000296 520.6699999999999591 Td +(Booking > pay) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 513.3899999999998727 96. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 501.92999999999995 Td +(Wagon allocated /) Tj +T* (payment window) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +138. 513.3899999999998727 257.2800000000000296 -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +143. 501.92999999999995 Td +(Scheduling) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +395.2800000000000296 513.3899999999998727 74. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +400.2800000000000296 501.92999999999995 Td +(In-app) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +469.2800000000000296 513.3899999999998727 84. -27.4799999999999969 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +474.2800000000000296 501.92999999999995 Td +(Booking) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.97 0.93 0.93 rg +42. 471.9099999999999113 511.2799999999999727 -62. re +f +0.62 0.17 0.2 rg +42. 471.9099999999999113 3. -62. re +f +BT +/F1 8.6 Tf +9.8899999999999988 TL +0.275 0.345 0.357 rg +56. 458.9099999999999113 Td +(DO NOT CHASE A MISSING IN-APP NOTIFICATION BEFORE CHECKING THIS. Recipients are resolved from the company's) Tj +T* (linked portal users. If a company has none, notify\(\) logs "0 recipients - skipped" and stores nothing - the notification simply never) Tj +T* (appears, with no error. SMS and email still go out, because they address the company's phone and email directly. On a fresh) Tj +T* (environment this is the usual explanation.) Tj +ET +BT +/F5 9 Tf +10.3499999999999996 TL +0.043 0.333 0.388 rg +42. 387.9099999999999113 Td +(11) Tj +ET +BT +/F9 17 Tf +19.5499999999999972 TL +0.063 0.102 0.11 rg +72. 386.9099999999999113 Td +(Known open issues) Tj +ET +0.06 0.1 0.11 RG +0.9 w +42. 366.9099999999999113 m +553.2799999999999727 366.9099999999999113 l +S +BT +/F1 9 Tf +10.3499999999999996 TL +0.275 0.345 0.357 rg +42. 352.9099999999999113 Td +(Do not raise duplicates for these. Each is already identified.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +42. 330.9099999999999113 78. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +47. 319.9599999999999227 Td +(Status) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +120. 330.9099999999999113 190. -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +125. 319.9599999999999227 Td +(Issue) Tj +ET +0.93 0.95 0.95 rg +0.8 0.85 0.85 RG +0.4 w +0.93 0.95 0.95 rg +310. 330.9099999999999113 243.2799999999999727 -18.0499999999999972 re +B +BT +/F2 7 Tf +8.0499999999999989 TL +0.427 0.498 0.51 rg +315. 319.9599999999999227 Td +(Impact on testing) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.97 0.93 0.93 rg +0.8 0.85 0.85 RG +0.4 w +0.97 0.93 0.93 rg +42. 312.8599999999999 78. -36.2199999999999989 re +B +BT +/F2 7.6 Tf +8.7399999999999984 TL +0.616 0.169 0.196 rg +47. 301.3999999999998636 Td +(OPEN) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +120. 312.8599999999999 190. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +125. 301.3999999999998636 Td +(Export receive returns 500 on the deployed) Tj +T* (environment \(POST) Tj +T* (/warehouse-inventory/receive-bulk\).) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +310. 312.8599999999999 243.2799999999999727 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +315. 301.3999999999998636 Td +(Blocks flows E1 and E2 at step .2. Awaiting the response body /) Tj +T* (server log to diagnose. Likely schema drift, not the SQL.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 276.6399999999998727 78. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 265.1799999999998363 Td +(FIX PENDING) Tj +T* (DEPLOY) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +120. 276.6399999999998727 190. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +125. 265.1799999999998363 Td +(Export receive was extremely slow. The owner SMS) Tj +T* (was awaited inside the DB transaction, and the SMS) Tj +T* (client had no HTTP timeout.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +310. 276.6399999999998727 243.2799999999999727 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +315. 265.1799999999998363 Td +(Fixed on branch: SMS now has a timeout, and notifications are sent) Tj +T* (after commit. Re-test receive latency once deployed.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 240.4199999999998454 78. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 228.959999999999809 Td +(DATA) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +120. 240.4199999999998454 190. -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +125. 228.959999999999809 Td +(Drivers table has no unique constraints on licence) Tj +T* (number, email, or phone, despite the entity declaring) Tj +T* (them unique.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +310. 240.4199999999998454 243.2799999999999727 -36.2199999999999989 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +315. 228.959999999999809 Td +(Duplicate drivers can be created. Do not rely on uniqueness in test) Tj +T* (assertions.) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +42. 204.1999999999998181 78. -53.6999999999999886 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.063 0.102 0.11 rg +47. 192.7399999999997817 Td +(BEHAVIOUR) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +120. 204.1999999999998181 190. -53.6999999999999886 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +125. 192.7399999999997817 Td +(Handover generated before truck arrival. If an) Tj +T* (operator triggers a signature request from the Exit) Tj +T* (Paper button while a truck is assigned but not arrived,) Tj +T* (Approve delivery refuses with "truck arrival must be) Tj +T* (recorded".) Tj +ET +1. g +0.8 0.85 0.85 RG +0.4 w +1. g +310. 204.1999999999998181 243.2799999999999727 -53.6999999999999886 re +B +BT +/F1 7.6 Tf +8.7399999999999984 TL +0.275 0.345 0.357 rg +315. 192.7399999999997817 Td +(Only reachable off the normal path. Follow flow I1 in order and it will) Tj +T* (not occur.) Tj +ET +0.06 0.1 0.11 RG +0.9 w +0.78 G +0. w +0.06 0.1 0.11 RG +0.9 w +0.06 0.1 0.11 RG +0.9 w +0.8 0.85 0.85 RG +0.5 w +42. 36. m +553.2799999999999727 36. l +S +BT +/F9 8 Tf +9.1999999999999993 TL +0.427 0.498 0.51 rg +42. 22. Td +(EDR Freight - QA Test Plan) Tj +ET +BT +/F5 7.5 Tf +8.625 TL +0.427 0.498 0.51 rg +530.7799999999999727 22. Td +(7 / 7) Tj +ET +endstream +endobj +1 0 obj +<> +endobj +17 0 obj +<< +/Type /Font +/BaseFont /Helvetica +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +18 0 obj +<< +/Type /Font +/BaseFont /Helvetica-Bold +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +19 0 obj +<< +/Type /Font +/BaseFont /Helvetica-Oblique +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +20 0 obj +<< +/Type /Font +/BaseFont /Helvetica-BoldOblique +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +21 0 obj +<< +/Type /Font +/BaseFont /Courier +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +22 0 obj +<< +/Type /Font +/BaseFont /Courier-Bold +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +23 0 obj +<< +/Type /Font +/BaseFont /Courier-Oblique +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +24 0 obj +<< +/Type /Font +/BaseFont /Courier-BoldOblique +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +25 0 obj +<< +/Type /Font +/BaseFont /Times-Roman +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +26 0 obj +<< +/Type /Font +/BaseFont /Times-Bold +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +27 0 obj +<< +/Type /Font +/BaseFont /Times-Italic +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +28 0 obj +<< +/Type /Font +/BaseFont /Times-BoldItalic +/Subtype /Type1 +/Encoding /WinAnsiEncoding +/FirstChar 32 +/LastChar 255 +>> +endobj +29 0 obj +<< +/Type /Font +/BaseFont /ZapfDingbats +/Subtype /Type1 +/FirstChar 32 +/LastChar 255 +>> +endobj +30 0 obj +<< +/Type /Font +/BaseFont /Symbol +/Subtype /Type1 +/FirstChar 32 +/LastChar 255 +>> +endobj +2 0 obj +<< +/ProcSet [/PDF /Text /ImageB /ImageC /ImageI] +/Font << +/F1 17 0 R +/F2 18 0 R +/F3 19 0 R +/F4 20 0 R +/F5 21 0 R +/F6 22 0 R +/F7 23 0 R +/F8 24 0 R +/F9 25 0 R +/F10 26 0 R +/F11 27 0 R +/F12 28 0 R +/F13 29 0 R +/F14 30 0 R +>> +/XObject << +>> +>> +endobj +31 0 obj +<< +/Producer (jsPDF 4.2.1) +/CreationDate (D:20260709090321-00'00') +>> +endobj +32 0 obj +<< +/Type /Catalog +/Pages 1 0 R +/OpenAction [3 0 R /FitH null] +/PageLayout /OneColumn +>> +endobj +xref +0 33 +0000000000 65535 f +0000115749 00000 n +0000117610 00000 n +0000000015 00000 n +0000000152 00000 n +0000010120 00000 n +0000010257 00000 n +0000026401 00000 n +0000026538 00000 n +0000045234 00000 n +0000045372 00000 n +0000065489 00000 n +0000065628 00000 n +0000079123 00000 n +0000079262 00000 n +0000100531 00000 n +0000100670 00000 n +0000115845 00000 n +0000115971 00000 n +0000116102 00000 n +0000116236 00000 n +0000116374 00000 n +0000116498 00000 n +0000116627 00000 n +0000116759 00000 n +0000116895 00000 n +0000117023 00000 n +0000117150 00000 n +0000117279 00000 n +0000117412 00000 n +0000117514 00000 n +0000117863 00000 n +0000117949 00000 n +trailer +<< +/Size 33 +/Root 32 0 R +/Info 31 0 R +/ID [ <3BEB58B86E90D0C327872D7AEEEBDAFB> <3BEB58B86E90D0C327872D7AEEEBDAFB> ] +>> +startxref +118053 +%%EOF \ No newline at end of file From cec544fc12c9150dafcd93514b7831cd58a5a805 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 9 Jul 2026 15:28:20 +0300 Subject: [PATCH 26/31] Coach type price change on seat selection updates --- .../backoffice/src/app/passengers/page.tsx | 2 +- .../backoffice/src/app/tariff-rates/page.tsx | 5 +- .../backoffice/src/app/tickets/page.tsx | 9 +- .../backoffice/src/lib/api/passengers.ts | 9 +- .../portal/src/app/booking/payment/page.tsx | 8 +- .../portal/src/app/booking/results/page.tsx | 12 +- .../portal/src/app/booking/review/page.tsx | 25 +- .../portal/src/app/booking/seats/page.tsx | 16 +- .../portal/src/app/packages/[id]/page.tsx | 254 ++++++++++-------- .../portal/src/lib/booking-store.ts | 4 +- 10 files changed, 208 insertions(+), 136 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index cff41ede2..e2c0fefb5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -140,7 +140,7 @@ export default function PassengersPage() {
), }, - { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' }, + { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || p.passenger?.user?.phone || '—' }, { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' }, { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' }, diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 33f39d87e..ad10052fd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -166,7 +166,10 @@ export default function TariffRatesPage() { }, { key: 'coachType', label: 'Coach Type', - render: (c: any) => {c.coachType?.name || c.coachTypeId}, + render: (c: any) => { + const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId); + return {ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}; + }, }, { key: 'bedPosition', label: 'Bed Position', diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index e0c7122e3..c737da4de 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -343,15 +343,12 @@ export default function TicketsPage() { key: 'contact', label: 'Contact', render: (ticket: any) => { - const phone = ticket.booking?.passenger?.phone || 'N/A'; - const email = ticket.booking?.passenger?.email || 'N/A'; - + const phone = ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || '—'; + const email = ticket.booking?.contactEmail || ticket.booking?.passenger?.email || '—'; return (
{phone}
-
-
{email}
-
+
{email}
); }, diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts b/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts index f7191e83b..f94e3d05f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts @@ -10,7 +10,10 @@ export const passengersApi = { if (filters?.role) params.append('role', filters.role); if (filters?.page) params.append('page', filters.page.toString()); if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString()); - + if ((filters as any)?.gender) params.append('gender', (filters as any).gender); + if ((filters as any)?.nationality) params.append('nationality', (filters as any).nationality); + if ((filters as any)?.dateFrom) params.append('dateFrom', (filters as any).dateFrom); + if ((filters as any)?.dateTo) params.append('dateTo', (filters as any).dateTo); return apiClient.get>(`/passengers?${params.toString()}`); }, @@ -21,4 +24,8 @@ export const passengersApi = { update: (id: string, data: Partial) => { return apiClient.patch(`/passengers/${id}`, data); }, + + delete: (id: string, cascade = false) => { + return apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`); + }, }; diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index ceb54acd9..3e9cecbc7 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -72,10 +72,10 @@ export default function PaymentPage() { // split equally across both legs. This guarantees leg totals are consistent with the // per-passenger breakdown rows and the overall reviewed total. const outboundBaseFare = isRoundTrip - ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.outboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; const inboundBaseFare = isRoundTrip - ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display @@ -307,11 +307,11 @@ export default function PaymentPage() {
Outbound - {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)} + {formatFare(reviewed?.outboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
Return - {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)} + {formatFare(reviewed?.inboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
)} diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 33609ba0e..fa52aad32 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -293,7 +293,17 @@ export default function ResultsPage() { // For round trip inbound, proceed with both schedules if (isRoundTrip && !isOutbound) { - setInboundSchedule(scheduleData); + // Mirror the outbound's coachTypes (fares) onto the inbound schedule so the + // return seat selection page shows the same prices as the outbound leg. + const inboundScheduleData = outboundScheduleData + ? { + ...scheduleData, + baseFareAdult: outboundScheduleData.baseFareAdult, + baseFareChild: outboundScheduleData.baseFareChild, + coachTypes: outboundScheduleData.coachTypes, + } + : scheduleData; + setInboundSchedule(inboundScheduleData); setSelectedSchedule(outboundScheduleData); // Set primary as outbound } else { // For one-way diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index cf14cc87c..57b098dc1 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -445,7 +445,9 @@ export default function ReviewPage() { const fareMinor = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? pkgFallback)) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); - return { fareMinor, isFree: isFreeChild }; + const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined; + const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined; + return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor }; }); setReviewedTotal(computedTotal, passengerFares); @@ -560,6 +562,14 @@ export default function ReviewPage() { const isFreeChild = isPackageBooking ? isPkgFreeChild(i) : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); + + // Per-leg fares for round trips + const outboundFare: number | null = isRoundTrip + ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null)) + : null; + const inboundFare: number | null = isRoundTrip + ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null)) + : null; const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare))) @@ -582,6 +592,19 @@ export default function ReviewPage() { {formatFare(passengerTotal, displayCurrency)}
+ {/* Round-trip: show outbound + inbound breakdown */} + {isRoundTrip && !isFreeChild && ( +
+
+ ↗ Outbound + {outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'} +
+
+ ↙ Return + {inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'} +
+
+ )}
); })} diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index f8fd23609..ccb953267 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -451,16 +451,15 @@ export default function SeatsPage() { // the new seat map data has finished loading. const applyCoachTypeSwitch = (coach: any, matchedType: any) => { const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId); - const firstClass = matchedType.classes?.[0]; const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId; const updatedSchedule = { ...(currentSchedule as any), selectedCoachTypeId: newCoachTypeId, selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "", selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "", - selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", - selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", - seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", + selectedSeatClass: matchedType.coachTypeName || coach.coachTypeName || "", + selectedSeatClassName: matchedType.coachTypeName || coach.coachTypeName || "", + seatClassName: matchedType.coachTypeName || coach.coachTypeName || "", baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult, baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild, }; @@ -921,7 +920,7 @@ export default function SeatsPage() { const positionLabel = newSeat?.bedPosition ? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth` : "This seat"; - const legMultiplier = isRoundTrip ? 2 : 1; + const legMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; setModalState({ isOpen: true, @@ -1328,15 +1327,16 @@ export default function SeatsPage() { useEffect(() => { if (isRoundTrip) { if (!outboundSchedule || !inboundSchedule || !passengers.length) { - router.push("/booking/search"); + router.push(isPackageBooking ? "/booking/passengers" : "/booking/search"); } } else { if (!selectedSchedule || !passengers.length) { - router.push("/booking/search"); + router.push(isPackageBooking ? "/booking/passengers" : "/booking/search"); } } }, [ isRoundTrip, + isPackageBooking, selectedSchedule, outboundSchedule, inboundSchedule, @@ -1712,7 +1712,7 @@ export default function SeatsPage() { if ( isRoundTrip - ? !outboundSchedule || !inboundSchedule || !passengers.length + ? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length : !selectedSchedule || !passengers.length ) return null; diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 32e3f1178..bb839906d 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -221,7 +221,7 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{ if (!map.has(key)) { map.set(key, { coachTypeId: ct?.id ?? key, - coachTypeName: ct?.name ?? tier.seatType, + coachTypeName: ct?.name ?? tier.label ?? tier.seatType, coachTypeCode: ct?.code ?? '', coachTypeType: ct?.type ?? 'passenger', tiers: [], @@ -270,90 +270,121 @@ function PriceTiersPanel({ } return ( -
-

Select Coach Type

- {groups.map((group) => { - const CoachIcon = getCoachIcon(group.coachTypeType); - const allSoldOut = group.tiers.every((t) => t.availableSeats === 0); - const isSelected = selectedId === group.coachTypeId; - return ( -
!allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)} - > - {/* Coach type header */} -
-
- -
-
-

- {formatCoachTypeLabel(group.coachTypeType)} -

-

- From {formatPrice(group.minPrice * priceMultiplier, group.currency)} - {allSoldOut && · Sold out} -

-
+
+

Choose Coach Type

+
+ {groups.map((group, index) => { + const CoachIcon = getCoachIcon(group.coachTypeType); + const allSoldOut = group.tiers.every((t) => t.availableSeats === 0); + const isSelected = selectedId === group.coachTypeId; + return ( +
!allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)} + onKeyDown={(e) => { + if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + setSelectedId(isSelected ? null : group.coachTypeId); + } + }} + className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut + ? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed' + : isSelected + ? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer' + : 'border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer' + }`} + style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both` }} + > + {/* Radio indicator */} {!allSoldOut && ( -
- {isSelected && } -
+ + {isSelected && } + )} -
- {/* All available classes for this coach type */} -
- {group.tiers.map((tier) => { - const soldOut = tier.availableSeats === 0; - return ( -
-
-
-

{tier.seatType.trim()}

-

- {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} - {soldOut ? ( - Sold out - ) : ( - {tier.availableSeats} left - )} -

+
+
+
+ +
+ +
+

+ {formatCoachTypeLabel(group.coachTypeType)} +

+ {allSoldOut && ( + Sold out + )} +
+ From + + {((group.minPrice * priceMultiplier) / 100).toFixed(2)} + + {group.currency}
- ); - })} -
+
- {/* Book Now — only when this group is selected */} - {isSelected && !allSoldOut && ( -
- + {/* Class options — always visible, matching results page style */} + {group.tiers.length > 0 && ( +
+
+ {group.tiers.map((tier) => { + const soldOut = tier.availableSeats === 0; + return ( +
+ {tier.seatType.trim()} + {soldOut ? ( + Sold out + ) : ( +
+ + {((tier.priceMinor * priceMultiplier) / 100).toFixed(2)} + + {tier.currency} +
+ )} +
+ ); + })} +
+
+ )} + + {!isSelected && !allSoldOut && ( +

+ Click to select this coach +

+ )} + + {isSelected && !allSoldOut && ( + + )}
- )} -
- ); - })} +
+ ); + })} +
+
); } @@ -407,7 +438,7 @@ function PassengerCountModal({

Coach type

-

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

+

{tier.seatClass?.coachType?.name ?? tier.label.trim()}

{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

@@ -416,26 +447,26 @@ function PassengerCountModal({ { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } }, { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount }, ].map(({ label, sub, value, min, max, set }) => ( -
-
-

{label}

-

{sub}

-
-
- - {value} - -
+
+
+

{label}

+

{sub}

- ))} +
+ + {value} + +
+
+ ))} {freeChildren > 0 && (
@@ -457,15 +488,14 @@ function PassengerCountModal({ {/* Departure Station */}
-