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) {