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.
This commit is contained in:
Marshal
2026-07-09 03:36:35 +00:00
parent addee34d5d
commit ddddcfb71f
39 changed files with 753 additions and 121 deletions

View File

@@ -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`).

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS overage_tolerance_tons,
DROP COLUMN IF EXISTS overage_tolerance_meters;
`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_types
DROP COLUMN IF EXISTS max_wagons_per_train;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_types
ADD COLUMN IF NOT EXISTS max_wagons_per_train INT;
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`DELETE FROM freight.wagon_types WHERE code = ANY($1);`,
[this.wagonTypes.map((wt) => wt.code)],
);
}
}

View File

@@ -993,6 +993,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.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<Booking> {
.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<Booking> {
.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<Booking> {
.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<Booking> {
.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();

View File

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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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';

View File

@@ -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<void> {
/**
* 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<number> {
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<void> {
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<void> {
// 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<void> {
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 {

View File

@@ -15,7 +15,6 @@ const nw5: WagonType = {
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,

View File

@@ -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<string, { code: string; count: number }> {

View File

@@ -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);
});
});

View File

@@ -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<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
locomotives: Array<
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): 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),
),
};
}

View File

@@ -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,

View File

@@ -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, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
locomotive?: Pick<
Locomotive,
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
>,
): Promise<Required<TrainLimitConfig>> {
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`,
);

View File

@@ -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);
});
});

View File

@@ -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<WagonType, 'lengthMeters'>,
limits?: TrainLimitConfig,
): string[] {
const violations: string[] = [];
@@ -478,7 +499,7 @@ export function validateMixedTrainLimits(
return validateTrainLimits(
wagonPlan,
{ maxWagonsPerTrain } as WagonType,
{ lengthMeters: minWagonLength },
{ ...limits, maxWagonsPerTrain },
);
}

View File

@@ -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],

View File

@@ -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[];

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -102,7 +102,6 @@ async function main() {
name: 'Negad Demo Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
equatedLengthM: 14,

View File

@@ -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,

View File

@@ -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 }))}
/>
<NumberInput
label="Max wagons per train"
min={0}
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
/>
<MantineSelect
label="Status"
value={form.isActive ? 'true' : 'false'}

View File

@@ -279,7 +279,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" },
{ id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" },
{
id: "supportedLoadTypes",
header: "Load types",
@@ -291,12 +290,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
{
name: "maxWagonsPerTrain",
label: "Max wagons per train",
type: "number",
optional: true,
},
{
name: "supportedLoadTypes",
label: "Supported load types",

View File

@@ -8,7 +8,6 @@ export interface WagonType {
name: string;
capacityTons: number;
lengthMeters: number;
maxWagonsPerTrain?: number | null;
supportedLoadTypes: string[];
isActive: boolean;
}

View File

@@ -334,6 +334,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
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,

View File

@@ -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")}
</Group>
</Group>

View File

@@ -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 (
<SectionCard p="lg">

View File

@@ -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)}
</span>
<span className="inline-flex items-center gap-[6px] rounded-full bg-[#F1F4F7] px-[11px] py-1.5 text-xs font-bold text-[#475569]">

View File

@@ -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 (
<Group

View File

@@ -1,12 +1,32 @@
import { Badge, Group, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
/**
* Shared presentation helpers for booking-like rows (one-time bookings AND
* general contracts). Kept in one place so the bookings list, contracts list,
* and detail page render type/freight/mode/payment consistently.
*/
/** Title-case an unmapped enum as a readable fallback ("FOO_BAR" → "Foo Bar"). */
export function titleCaseStatus(status: string): string {
return status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => 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<string, string> = {
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<string, string> = {
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 <Text fz={13} c="dimmed"></Text>;
@@ -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)}
</Badge>
);
}

View File

@@ -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<string, string> = {
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)}
</Badge>
</Group>

View File

@@ -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({
<Text fz={12} c="dimmed">
Global Logistics has created your shipment booking
{clearance.linkedBookingStatus
? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})`
? ` (${bookingStatusLabel(clearance.linkedBookingStatus).toLowerCase()})`
: ""}
. Track its progress from the booking.
</Text>

View File

@@ -85,6 +85,37 @@ const CLEARANCE_UPLOAD_STATUSES = [
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// Customer-facing labels for a shipment-request status (BOOKING_REQUEST_STATUSES).
const BOOKING_REQUEST_STATUS_LABELS: Record<string, string> = {
PENDING: "Pending review",
ACCEPTED: "Accepted",
REJECTED: "Rejected",
CANCELLED: "Cancelled",
};
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
const INVOICE_STATUS_LABELS: Record<string, string> = {
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()}
</Badge>
{clearanceView.riskAssignedAt ? (
<Text fz={12} c="dimmed">
@@ -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}
</Badge>
{req.createdBookingId ? (
<ChevronRight size={16} color={MUTED} />
@@ -1722,7 +1757,7 @@ function FinalInvoiceDueCard({
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{invoice.status}
{invoiceStatusLabel(invoice.status)}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>

View File

@@ -1151,6 +1151,9 @@ function ContainerLineEditor({
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}

View File

@@ -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",
};

View File

@@ -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<string, number>();
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) {