mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #561 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Tare weight becomes mandatory on a wagon type.
|
||||
*
|
||||
* The locomotive's pull limit is a GROSS limit — it drags the wagon as well as
|
||||
* the cargo — so capacity math cannot run without a tare. A NULL tare silently
|
||||
* read as zero and let trains overbook by the tare fraction (~27% on a PW2
|
||||
* consist), so the column is now NOT NULL.
|
||||
*
|
||||
* Any row still missing a tare predates 2060000000000-SeedRailWagonTypes (which
|
||||
* upserts the ten real EDR types). Backfill those by code first, and give any
|
||||
* remaining custom/demo type the NW5 flat-wagon tare rather than fail the
|
||||
* migration — a wrong-but-plausible tare is recoverable in the admin UI; a
|
||||
* blocked deploy is not.
|
||||
*/
|
||||
export class MakeWagonTypeTareWeightRequired2070000000000 implements MigrationInterface {
|
||||
private readonly tareByCode: Array<[string, number]> = [
|
||||
['NW7', 37.1],
|
||||
['NW5', 22.4],
|
||||
['PW2', 25.2],
|
||||
['GW2', 23],
|
||||
['CW4', 24.8],
|
||||
['CW3', 23.4],
|
||||
['KW2', 25.2],
|
||||
['KW3', 24],
|
||||
['NW6', 25.3],
|
||||
['BW1', 32.1],
|
||||
];
|
||||
|
||||
/** NW5 flat wagon — the commonest type in the fleet (550 of 1100). */
|
||||
private readonly fallbackTareTons = 22.4;
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const [code, tareWeightTons] of this.tareByCode) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.wagon_types
|
||||
SET tare_weight_tons = $2
|
||||
WHERE code = $1 AND tare_weight_tons IS NULL;`,
|
||||
[code, tareWeightTons],
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.wagon_types
|
||||
SET tare_weight_tons = $1
|
||||
WHERE tare_weight_tons IS NULL;`,
|
||||
[this.fallbackTareTons],
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_types
|
||||
ALTER COLUMN tare_weight_tons SET NOT NULL;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_types
|
||||
ALTER COLUMN tare_weight_tons DROP NOT NULL;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,13 @@ export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default CW3 covered wagon length for bulk bookings (m). */
|
||||
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/**
|
||||
* Fallback tare weights (T) matching the length fallbacks above. The locomotive
|
||||
* pull limit is a GROSS limit, so a booking's weight budget must include the
|
||||
* empty weight of every wagon it occupies — not just its cargo.
|
||||
*/
|
||||
export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4;
|
||||
|
||||
/** Default CW3 gondola tare for bulk bookings (T). */
|
||||
export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4;
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
createMany: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: {
|
||||
findById: jest.Mock;
|
||||
findByIdWithFullGraph: jest.Mock;
|
||||
findAll: jest.Mock;
|
||||
};
|
||||
@@ -65,6 +66,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
createMany: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
id: scheduleId,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowPhase: null,
|
||||
}),
|
||||
findByIdWithFullGraph: jest.fn().mockResolvedValue({
|
||||
id: scheduleId,
|
||||
maxWagons: 10,
|
||||
@@ -407,6 +413,49 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
// Never reserved — waits for its partner in a later cycle.
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a stale FULL flag and fills a train whose bookings all expired', async () => {
|
||||
// The deadlock: train A filled once, every booking then expired, but
|
||||
// bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever
|
||||
// reads the budget, so the batch skipped the train forever — it just cycled
|
||||
// PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd
|
||||
// already-pinned booking got settled, one per cycle.
|
||||
const staleFull = {
|
||||
id: trainA,
|
||||
maxWagons: 1,
|
||||
bookingWindowStatus: 'FULL',
|
||||
// The batch runs while the customer window is closed.
|
||||
windowPhase: 'PAYMENT',
|
||||
direction: 'IMPORT',
|
||||
trainSetId: `set-${trainA}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
};
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]);
|
||||
// Live capacity says the train is empty: 1 free wagon, nothing allocated.
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull);
|
||||
// refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase);
|
||||
// the re-read reports it, and isFillable() admits CLOSED during PAYMENT.
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: trainA,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PAYMENT',
|
||||
});
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||
commercial('waiting', 30),
|
||||
]);
|
||||
|
||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
// The train was reopened to the batch and actually filled, not skipped.
|
||||
expect(touched).toEqual([trainA]);
|
||||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
|
||||
expect(notifier.unplaced).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
|
||||
|
||||
@@ -30,12 +30,17 @@ import { BillingService } from "../billing/billing.service";
|
||||
|
||||
import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
WagonTypeDimensions,
|
||||
bookingGrossWeightTons,
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
trainHardCaps,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
@@ -63,7 +68,14 @@ interface RouteDayGroup {
|
||||
day: string;
|
||||
}
|
||||
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
/**
|
||||
* Per-freight-type wagon dimensions used to size a booking's capacity draw:
|
||||
* its length on the train and the tare it adds to the locomotive's gross load.
|
||||
*/
|
||||
type WagonDims = {
|
||||
container: { lengthMeters: number; tareWeightTons: number };
|
||||
bulk: { lengthMeters: number; tareWeightTons: number };
|
||||
};
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| "ALLOCATED"
|
||||
@@ -501,8 +513,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const required = need ?? this.needFor(booking, wagonLengths);
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const required = need ?? this.needFor(booking, wagonDims);
|
||||
let corridorMatched = false;
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
@@ -511,7 +523,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
corridorMatched = true;
|
||||
@@ -549,8 +561,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!partner || partner.status !== 'FULLY_EXECUTED') {
|
||||
return;
|
||||
}
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const need = this.combinedNeed(booking, partner, wagonLengths);
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const need = this.combinedNeed(booking, partner, wagonDims);
|
||||
const scheduleId = await this.pickExportSchedule(booking, need);
|
||||
await this.reserveOnExport([booking, partner], scheduleId);
|
||||
}
|
||||
@@ -620,7 +632,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
order: { scheduledDepartureDate: "ASC" },
|
||||
});
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const rules = await this.loadGlobalRules();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
@@ -635,7 +648,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
const need = this.needFor(b, wagonDims);
|
||||
return {
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
@@ -655,7 +668,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
});
|
||||
|
||||
board.push(this.buildScheduleSummary(s, items));
|
||||
board.push(this.buildScheduleSummary(s, items, rules));
|
||||
}
|
||||
return board;
|
||||
}
|
||||
@@ -678,7 +691,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const rules = await this.loadGlobalRules();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
@@ -724,7 +738,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
const need = this.needFor(b, wagonDims);
|
||||
const alloc = allocationByBooking.get(b.id);
|
||||
return {
|
||||
id: b.id,
|
||||
@@ -871,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
@@ -902,6 +916,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
|
||||
* includes the tare of the wagons it occupies), so the ceiling it is measured
|
||||
* against must be the same one the fill loop spends from: the locomotive floored
|
||||
* by the global rule caps and widened by its overage tolerance. Reading the raw
|
||||
* `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use.
|
||||
*/
|
||||
private computeBoardCapacity(
|
||||
items: Array<{
|
||||
state: BatchBoardBookingState;
|
||||
@@ -911,22 +932,40 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
maxWagons: number | null,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
const committed = items.filter(
|
||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
||||
);
|
||||
const caps = loco
|
||||
? trainHardCaps(
|
||||
{
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
|
||||
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
|
||||
},
|
||||
{
|
||||
maxTrainWeightTons: rules?.maxTrainWeightTons
|
||||
? Number(rules.maxTrainWeightTons)
|
||||
: undefined,
|
||||
maxTrainLengthMeters: rules?.maxTrainLengthMeters
|
||||
? Number(rules.maxTrainLengthMeters)
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
: null;
|
||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
return {
|
||||
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
||||
allocatedLengthMeters:
|
||||
Math.round(
|
||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
|
||||
) / 100,
|
||||
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
|
||||
usedWeightTons:
|
||||
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
|
||||
100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
allocatedLengthMeters: round2(
|
||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
|
||||
),
|
||||
maxLengthMeters: caps ? caps.maxLengthMeters : null,
|
||||
usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
|
||||
maxWeightTons: caps ? caps.maxWeightTons : null,
|
||||
maxWagons: maxWagons ?? null,
|
||||
};
|
||||
}
|
||||
@@ -934,6 +973,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private buildScheduleSummary(
|
||||
s: TrainSchedule,
|
||||
items: BatchBoardBooking[],
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
): BatchBoardSchedule {
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
@@ -966,7 +1006,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
@@ -1036,10 +1076,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
if (budget.maxRemaining().wagons <= 0) {
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
return 0;
|
||||
@@ -1064,8 +1104,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
const need = isPair
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
? this.combinedNeed(booking, partner, wagonDims)
|
||||
: this.needFor(booking, wagonDims);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
// Consolidated partners always share one corridor, so the primary's leg
|
||||
// stands for the pair.
|
||||
@@ -1084,7 +1124,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
need,
|
||||
leg,
|
||||
budget,
|
||||
wagonLengths,
|
||||
wagonDims,
|
||||
);
|
||||
if (!freed) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
@@ -1151,6 +1191,42 @@ export class BookingBatchService implements OnModuleInit {
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<string[]> {
|
||||
const { scheduleIds } = await this.fillRouteDayInternal(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
return scheduleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-day top-up for a single schedule: re-run the DAY pool over the whole
|
||||
* corridor the schedule belongs to, and report how many commercial units got a
|
||||
* fresh pay window.
|
||||
*
|
||||
* `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on
|
||||
* `booking.train_schedule_id = :scheduleId`, but under day-level pooling a
|
||||
* booking that has not been reserved yet has a NULL `train_schedule_id` — it is
|
||||
* only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows
|
||||
* and the waiting list never boarded after an expiry freed capacity; bookings
|
||||
* trickled in one per window cycle instead.
|
||||
*/
|
||||
private async topUpFill(scheduleId: string): Promise<number> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (!schedule?.scheduledDepartureDate) return 0;
|
||||
const { commercialReserved } = await this.fillRouteDayInternal(
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
eatDay(schedule.scheduledDepartureDate),
|
||||
);
|
||||
return commercialReserved;
|
||||
}
|
||||
|
||||
private async fillRouteDayInternal(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<{ scheduleIds: string[]; commercialReserved: number }> {
|
||||
// The day's fillable schedules on this exact corridor, earliest first. Fillable
|
||||
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
|
||||
// the batch must run while the customer window is closed.
|
||||
@@ -1168,23 +1244,36 @@ export class BookingBatchService implements OnModuleInit {
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduleIds = corridor
|
||||
const onDay = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
this.isFillable(s),
|
||||
eatDay(s.scheduledDepartureDate) === day,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
);
|
||||
|
||||
if (scheduleIds.length === 0) return [];
|
||||
// A schedule flagged FULL is rejected by isFillable() before its budget is
|
||||
// ever consulted. Re-derive that flag from live capacity first, so a train
|
||||
// whose bookings all expired is not skipped forever with an empty consist.
|
||||
for (const s of onDay) {
|
||||
if (s.bookingWindowStatus === "FULL") {
|
||||
await this.refreshWindowStatus(s.id);
|
||||
const fresh = await this.trainSchedulesRepository.findById(s.id);
|
||||
if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id);
|
||||
|
||||
if (scheduleIds.length === 0) {
|
||||
return { scheduleIds: [], commercialReserved: 0 };
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
|
||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
||||
@@ -1200,10 +1289,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
|
||||
|
||||
// The day pool covers every booking whose leg lies somewhere on one of the
|
||||
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
|
||||
@@ -1225,13 +1314,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
let reservedThisPass = 0;
|
||||
let commercialReserved = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
const need = isPair
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
? this.combinedNeed(booking, partner, wagonDims)
|
||||
: this.needFor(booking, wagonDims);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
|
||||
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
||||
@@ -1267,7 +1357,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
need,
|
||||
leg,
|
||||
t.budget,
|
||||
wagonLengths,
|
||||
wagonDims,
|
||||
);
|
||||
if (freed) {
|
||||
target = t;
|
||||
@@ -1284,7 +1374,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// non-import never split — isSplitEligible guards that. Passing the live
|
||||
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
||||
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
|
||||
if (offered) continue;
|
||||
if (offered) {
|
||||
// A partial offer opens a real commercial pay window, same as reserve().
|
||||
commercialReserved += 1;
|
||||
reservedThisPass += 1;
|
||||
continue;
|
||||
}
|
||||
// Stays in the pool, retried next batch/window cycle.
|
||||
this.notifier.unplaced(booking, day);
|
||||
if (partner) this.notifier.unplaced(partner, day);
|
||||
@@ -1305,6 +1400,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.reserve(booking, target.id);
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
commercialReserved += 1;
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
reservedThisPass += 1;
|
||||
@@ -1326,7 +1422,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
|
||||
return trains.map((t) => t.id);
|
||||
return { scheduleIds: trains.map((t) => t.id), commercialReserved };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1396,7 +1492,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (booking.consolidationPartnerId) return null;
|
||||
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
|
||||
const sized = await this.splitService.sizeOffer(
|
||||
booking,
|
||||
@@ -1408,11 +1504,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const offeredNeed: Capacity = {
|
||||
wagons: sized.offeredWagons,
|
||||
weightTons: sized.offeredWeightTons,
|
||||
lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
weightTons: bookingGrossWeightTons(
|
||||
sized.offeredWeightTons,
|
||||
sized.offeredWagons,
|
||||
this.tareFor(booking.freightType, wagonDims),
|
||||
),
|
||||
lengthMeters: bookingTrainLengthMeters(
|
||||
booking.freightType,
|
||||
sized.offeredWagons,
|
||||
this.lengthsOf(wagonDims),
|
||||
),
|
||||
};
|
||||
if (!this.fits(offeredNeed, budget)) return null;
|
||||
|
||||
@@ -1510,7 +1611,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
|
||||
);
|
||||
const topUpReserved = await this.fillSchedule(scheduleId);
|
||||
const topUpReserved = await this.topUpFill(scheduleId);
|
||||
// A top-up opened a fresh pay window for waiting bookings — push the
|
||||
// schedule's PAYMENT phase out so the window tick's concludeCycle doesn't
|
||||
// fire before those customers' new deadlines and expire them prematurely.
|
||||
@@ -1526,7 +1627,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async settleBatch(scheduleId: string): Promise<void> {
|
||||
this.removeTimeout(scheduleId);
|
||||
await this.settleReserved(scheduleId, true);
|
||||
const topUpReserved = await this.fillSchedule(scheduleId);
|
||||
const topUpReserved = await this.topUpFill(scheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
@@ -1632,11 +1733,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
// Capture the train before expire() detaches the booking from it — the
|
||||
// top-up has to run against the schedule whose wagons were just freed.
|
||||
const freedScheduleId = booking.trainScheduleId;
|
||||
await this.expire(booking);
|
||||
if (booking.trainScheduleId) {
|
||||
const topUpReserved = await this.fillSchedule(booking.trainScheduleId);
|
||||
if (freedScheduleId) {
|
||||
const topUpReserved = await this.topUpFill(freedScheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(booking.trainScheduleId);
|
||||
await this.extendPaymentPhaseForTopUp(freedScheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1659,10 +1763,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) return null;
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1827,6 +1931,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||
*/
|
||||
private async expire(booking: Booking): Promise<void> {
|
||||
const freedScheduleId = booking.trainScheduleId;
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
status: "EXPIRED",
|
||||
@@ -1835,6 +1940,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
// The wagons this reservation held are back — a schedule parked at FULL
|
||||
// because of it must reopen, or it can never be filled again.
|
||||
if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId);
|
||||
// An unpaid partial offer dies with the reservation — the booking stays whole.
|
||||
if (this.splitService) {
|
||||
await this.splitService.expireOpenOffer(booking.id);
|
||||
@@ -1939,7 +2047,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
need: Capacity,
|
||||
leg: CorridorLeg,
|
||||
budget: CorridorBudget,
|
||||
wagonLengths: WagonLengths,
|
||||
wagonDims: WagonDims,
|
||||
): Promise<boolean> {
|
||||
if (budget.fits(need, leg)) return true;
|
||||
const reservedCommercial = (
|
||||
@@ -1987,7 +2095,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
budget.add(this.needFor(victim, wagonLengths), victimLeg);
|
||||
budget.add(this.needFor(victim, wagonDims), victimLeg);
|
||||
// Displacing frees wagons the same way an expiry does — don't leave the
|
||||
// schedule stuck at FULL.
|
||||
await this.refreshWindowStatus(scheduleId);
|
||||
}
|
||||
return budget.fits(need, leg);
|
||||
}
|
||||
@@ -2041,7 +2152,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private combinedNeed(
|
||||
primary: Booking,
|
||||
partner: Booking,
|
||||
wagonLengths: WagonLengths,
|
||||
wagonDims: WagonDims,
|
||||
): Capacity {
|
||||
const containers = (b: Booking): number =>
|
||||
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
@@ -2050,15 +2161,36 @@ export class BookingBatchService implements OnModuleInit {
|
||||
totalContainers > 0
|
||||
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
|
||||
: this.wagonsFor(primary) + this.wagonsFor(partner);
|
||||
const weightTons =
|
||||
const cargoTons =
|
||||
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||
return {
|
||||
wagons: sharedWagons,
|
||||
weightTons,
|
||||
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
// Consolidation saves tare as well as slots: the pair rides `sharedWagons`
|
||||
// wagons, so it is charged `sharedWagons` tares, not one per booking.
|
||||
weightTons: bookingGrossWeightTons(
|
||||
cargoTons,
|
||||
sharedWagons,
|
||||
this.tareFor(primary.freightType, wagonDims),
|
||||
),
|
||||
lengthMeters: bookingTrainLengthMeters(
|
||||
primary.freightType,
|
||||
sharedWagons,
|
||||
this.lengthsOf(wagonDims),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-wagon tare for the wagon type this freight rides on. */
|
||||
private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number {
|
||||
return freightType === 'BULK'
|
||||
? wagonDims.bulk.tareWeightTons
|
||||
: wagonDims.container.tareWeightTons;
|
||||
}
|
||||
|
||||
private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } {
|
||||
return {
|
||||
container: wagonDims.container.lengthMeters,
|
||||
bulk: wagonDims.bulk.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2077,16 +2209,28 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers);
|
||||
}
|
||||
|
||||
/** What one booking consumes along all three capacity axes. */
|
||||
private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity {
|
||||
/**
|
||||
* What one booking consumes along all three capacity axes.
|
||||
*
|
||||
* The weight axis is GROSS — cargo plus the tare of every wagon the booking
|
||||
* occupies — because it is spent against the locomotive's pull limit, which
|
||||
* governs the whole train and not just its payload. Charging cargo alone let a
|
||||
* 37-wagon box-wagon train read 2590T when it really weighed 3522T.
|
||||
*/
|
||||
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
|
||||
const wagons = this.wagonsFor(booking);
|
||||
return {
|
||||
wagons,
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
weightTons: bookingGrossWeightTons(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
wagons,
|
||||
this.tareFor(booking.freightType, wagonDims),
|
||||
),
|
||||
lengthMeters: bookingTrainLengthMeters(
|
||||
booking.freightType,
|
||||
wagons,
|
||||
this.lengthsOf(wagonDims),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2098,7 +2242,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
|
||||
/**
|
||||
* Hard caps for a schedule's train: gross pull weight, train length, and the
|
||||
* length-derived wagon slot count (never a fixed 53). Bookings spend against
|
||||
* these via {@link needFor}, whose weight axis is gross.
|
||||
*/
|
||||
private async capacityLimits(
|
||||
locomotive: Locomotive,
|
||||
rules: TrainSchedulingGlobalRules | null,
|
||||
@@ -2143,31 +2291,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadWagonTypeDimensions(): Promise<
|
||||
Array<{ lengthMeters: number; capacityTons: number }>
|
||||
> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: "NW5" }, { code: "CW3" }],
|
||||
});
|
||||
/**
|
||||
* Every active wagon type, so the slot count is derived from the shortest wagon
|
||||
* the fleet can actually marshal rather than from an arbitrary two-code sample.
|
||||
*/
|
||||
private async loadWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
|
||||
const types = await this.dataSource
|
||||
.getRepository(WagonType)
|
||||
.find({ where: { isActive: true } });
|
||||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||||
return [
|
||||
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
|
||||
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
|
||||
{
|
||||
lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
capacityTons: 70,
|
||||
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
},
|
||||
{
|
||||
lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
capacityTons: 60,
|
||||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async loadWagonLengths(): Promise<WagonLengths> {
|
||||
/** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */
|
||||
private async loadWagonDims(): Promise<WagonDims> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: "NW5" }, { code: "CW3" }],
|
||||
});
|
||||
const byCode = new Map(
|
||||
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
||||
);
|
||||
const nw5 = byCode.get("NW5");
|
||||
const cw3 = byCode.get("CW3");
|
||||
return {
|
||||
container:
|
||||
byCode.get("NW5")?.lengthMeters ??
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
container: {
|
||||
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
},
|
||||
bulk: {
|
||||
lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2204,7 +2369,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async remainingBudget(
|
||||
schedule: TrainSchedule,
|
||||
limits: Capacity,
|
||||
wagonLengths: WagonLengths,
|
||||
wagonDims: WagonDims,
|
||||
): Promise<CorridorBudget> {
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits);
|
||||
@@ -2216,7 +2381,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
for (const b of [...allocated, ...reserved]) {
|
||||
budget.subtract(
|
||||
this.needFor(b, wagonLengths),
|
||||
this.needFor(b, wagonDims),
|
||||
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||
);
|
||||
}
|
||||
@@ -2228,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
|
||||
*/
|
||||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const budget = await this.remainingBudget(
|
||||
schedule,
|
||||
{
|
||||
@@ -2236,7 +2401,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
wagonLengths,
|
||||
wagonDims,
|
||||
);
|
||||
return budget.maxRemaining().wagons;
|
||||
}
|
||||
@@ -2269,6 +2434,35 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return (await this.remainingWagons(schedule)) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive `bookingWindowStatus` from live capacity after wagons were freed
|
||||
* (a reservation expired, a booking was displaced, a link was removed).
|
||||
*
|
||||
* FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before
|
||||
* it ever looks at the budget, and the only writers of OPEN skip a FULL row. So
|
||||
* a train that filled once and then lost every booking to expiry stayed FULL
|
||||
* with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT
|
||||
* forever while `concludeCycle` (which reads real capacity, not the flag) kept
|
||||
* reopening it. Clearing FULL here is what lets the next batch actually run.
|
||||
*
|
||||
* Only the customer-facing OPEN phases may go back to OPEN; a schedule mid
|
||||
* DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits.
|
||||
*/
|
||||
async refreshWindowStatus(scheduleId: string): Promise<void> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return;
|
||||
|
||||
const customerWindowOpen =
|
||||
schedule.windowPhase == null || schedule.windowPhase === "OPEN";
|
||||
await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED");
|
||||
this.logger.log(
|
||||
`[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` +
|
||||
`${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- timer plumbing -------------------------------------------------------
|
||||
|
||||
/** Configured customer pay window in ms (global rules, with defaults). */
|
||||
|
||||
@@ -328,6 +328,18 @@ export class BookingWindowService implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not full, so any FULL flag left over from a batch whose bookings later
|
||||
// expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below
|
||||
// refuses to reopen a FULL schedule, which is how a train with an empty
|
||||
// consist used to cycle forever without ever being fillable again. Re-read
|
||||
// the flag onto the in-memory row — advanceSchedule keeps looping on this
|
||||
// same object, and PRE_WINDOW→OPEN reads it.
|
||||
if (schedule.bookingWindowStatus === 'FULL') {
|
||||
await this.bookingBatchService.refreshWindowStatus(schedule.id);
|
||||
const fresh = await this.trainSchedulesRepository.findById(schedule.id);
|
||||
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
|
||||
}
|
||||
|
||||
// Doc review + payment have already run, so the desk is ready to reopen NOW —
|
||||
// office hours decide whether that is this afternoon or tomorrow morning. Past
|
||||
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
|
||||
|
||||
@@ -1,64 +1,183 @@
|
||||
import {
|
||||
bookingGrossWeightTons,
|
||||
bookingTrainLengthMeters,
|
||||
consistUsage,
|
||||
consistViolations,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
grossWagonWeightTons,
|
||||
minLocomotiveLimits,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
const nw5 = { lengthMeters: 14, capacityTons: 70 };
|
||||
// Real EDR wagon specs.
|
||||
const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 };
|
||||
const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 };
|
||||
const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 };
|
||||
|
||||
it('derives wagon slots from locomotive length and weight, not a fixed 53', () => {
|
||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
|
||||
[nw5],
|
||||
);
|
||||
expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14
|
||||
expect(shortLoco.maxWagonSlots).not.toBe(53);
|
||||
|
||||
const heavyLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70
|
||||
const caps = (over = {}) => ({
|
||||
maxWeightTons: 3500,
|
||||
maxLengthMeters: 760,
|
||||
maxWagonSlots: 54,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('uses shortest wagon type when mixed types are present', () => {
|
||||
const longBulk = { lengthMeters: 18, capacityTons: 80 };
|
||||
const mixed = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5, longBulk],
|
||||
);
|
||||
expect(mixed.maxWagonSlots).toBe(
|
||||
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
|
||||
);
|
||||
const slots = (n: number, type: typeof nw5, cargoTons: number) =>
|
||||
Array.from({ length: n }, () => ({
|
||||
lengthMeters: type.lengthMeters,
|
||||
tareWeightTons: type.tareWeightTons,
|
||||
cargoTons,
|
||||
}));
|
||||
|
||||
describe('deriveTrainCapacityFromLocomotive', () => {
|
||||
it('derives wagon slots from train length, not a fixed 53', () => {
|
||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
|
||||
[nw5],
|
||||
);
|
||||
expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966)
|
||||
expect(shortLoco.maxWagonSlots).not.toBe(53);
|
||||
});
|
||||
|
||||
it('does not shrink slots by assuming every wagon rides at full payload', () => {
|
||||
// A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a
|
||||
// LENGTH figure — the cargo that decides weight does not exist yet.
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30
|
||||
expect(derived.maxWeightTons).toBe(2100);
|
||||
});
|
||||
|
||||
it('admits the railway 53-wagon NW5 marshalling figure', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53);
|
||||
});
|
||||
|
||||
it('uses the shortest wagon type when mixed types are present', () => {
|
||||
const mixed = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5, pw2, gw2],
|
||||
);
|
||||
expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62
|
||||
});
|
||||
|
||||
it('extends weight/length caps by the locomotive overage tolerance', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
[pw2],
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3590);
|
||||
});
|
||||
|
||||
it('ignores overage tolerance when unset (strict cap)', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3500);
|
||||
expect(derived.maxLengthMeters).toBe(760);
|
||||
});
|
||||
|
||||
it('floors the locomotive by the global rule caps', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 5000, maxTrainLengthMeters: 900 },
|
||||
[nw5],
|
||||
{ maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3500);
|
||||
expect(derived.maxLengthMeters).toBe(760);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gross weight', () => {
|
||||
it('counts the wagon as well as its cargo', () => {
|
||||
expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2);
|
||||
});
|
||||
|
||||
it('charges a booking one tare per wagon it occupies', () => {
|
||||
// 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel.
|
||||
expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2);
|
||||
});
|
||||
|
||||
it('is cargo alone when the wagon type has no tare on record', () => {
|
||||
expect(bookingGrossWeightTons(100, 3, 0)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistUsage', () => {
|
||||
it('sums each wagon own length and tare rather than averaging a type', () => {
|
||||
const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)];
|
||||
const usage = consistUsage(mixed, caps());
|
||||
|
||||
expect(usage.wagonCount).toBe(3);
|
||||
expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066
|
||||
expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2
|
||||
expect(usage.usedCargoWeightTons).toBe(40);
|
||||
expect(usage.usedGrossWeightTons).toBe(110);
|
||||
expect(usage.remainingGrossWeightTons).toBe(3390);
|
||||
expect(usage.remainingWagons).toBe(51);
|
||||
});
|
||||
|
||||
it('reports an empty consist as fully available', () => {
|
||||
const usage = consistUsage([], caps());
|
||||
expect(usage.usedGrossWeightTons).toBe(0);
|
||||
expect(usage.remainingLengthMeters).toBe(760);
|
||||
expect(usage.remainingWagons).toBe(54);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistViolations', () => {
|
||||
it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => {
|
||||
// 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T.
|
||||
const consist = slots(37, pw2, 70);
|
||||
|
||||
expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([
|
||||
expect.stringContaining('3522.4T'),
|
||||
]);
|
||||
expect(
|
||||
consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('blocks a train the old cargo-only math would have waved through', () => {
|
||||
// Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T.
|
||||
const consist = slots(37, pw2, 70);
|
||||
const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0);
|
||||
|
||||
expect(cargoOnly).toBeLessThan(3500);
|
||||
expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => {
|
||||
// 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T.
|
||||
expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags an over-length consist', () => {
|
||||
const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 }));
|
||||
expect(violations).toEqual([expect.stringContaining('exceeds max train length')]);
|
||||
});
|
||||
|
||||
it('flags an over-count consist', () => {
|
||||
const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 }));
|
||||
expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]);
|
||||
});
|
||||
|
||||
it('reports every broken axis at once', () => {
|
||||
expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('computes booking length by freight type', () => {
|
||||
expect(
|
||||
bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }),
|
||||
).toBe(28);
|
||||
expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28);
|
||||
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
||||
});
|
||||
|
||||
it('extends weight/length caps by the locomotive overage tolerance (fertilizer +90T example)', () => {
|
||||
const pw2 = { lengthMeters: 17.066, capacityTons: 70 };
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
[pw2],
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3590);
|
||||
});
|
||||
|
||||
it('ignores overage tolerance when unset (strict cap, no behavior change)', () => {
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[{ lengthMeters: 14, capacityTons: 70 }],
|
||||
);
|
||||
expect(derived.maxWeightTons).toBe(3500);
|
||||
expect(derived.maxLengthMeters).toBe(760);
|
||||
});
|
||||
|
||||
it('takes the weakest locomotive tolerance across a multi-locomotive set', () => {
|
||||
it('takes the weakest locomotive across a multi-locomotive set', () => {
|
||||
const limits = minLocomotiveLimits([
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
/**
|
||||
* Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable:
|
||||
*
|
||||
* count — how many wagons fit end to end on the longest allowed train
|
||||
* length — Σ wagonType.lengthMeters over the real consist
|
||||
* weight — Σ (wagonType.tareWeightTons + cargoTons) over the real consist
|
||||
*
|
||||
* The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it.
|
||||
* The old code compared the locomotive's pull limit against cargo payload alone
|
||||
* and so overbooked every train by roughly the tare fraction (~27% on PW2).
|
||||
*
|
||||
* The weight axis is also driven by ACTUAL booked cargo, never by an assumed
|
||||
* full payload. That is what makes the real EDR numbers fall out:
|
||||
*
|
||||
* NW5 13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon
|
||||
* marshalling figure is length-bound, and those trains never carry 53×70T.
|
||||
* PW2 17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T,
|
||||
* which clears 3500T only via the locomotive's overage tolerance. Weight
|
||||
* binds first, hence "37 wagons per train".
|
||||
*
|
||||
* So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo
|
||||
* exists. Weight is enforced against the consist as bookings are allocated.
|
||||
*/
|
||||
|
||||
/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
|
||||
export type WagonTypeDimensions = {
|
||||
lengthMeters: number;
|
||||
capacityTons: number;
|
||||
tareWeightTons: number;
|
||||
};
|
||||
|
||||
/** One occupied wagon slot in a real consist. */
|
||||
export type ConsistSlot = {
|
||||
lengthMeters: number;
|
||||
tareWeightTons: number;
|
||||
/** Actual cargo/container weight riding on this wagon, not its rated capacity. */
|
||||
cargoTons: number;
|
||||
};
|
||||
|
||||
export type LocomotiveLimits = {
|
||||
@@ -14,69 +47,166 @@ export type LocomotiveLimits = {
|
||||
};
|
||||
|
||||
export type DerivedTrainCapacity = {
|
||||
/** Gross (tare + cargo) tons the train may weigh, tolerance included. */
|
||||
maxWeightTons: number;
|
||||
maxLengthMeters: number;
|
||||
/** Length-derived slot count. Weight is enforced separately against real cargo. */
|
||||
maxWagonSlots: number;
|
||||
};
|
||||
|
||||
/** What a consist currently uses, and what is left on each axis. */
|
||||
export type ConsistUsage = {
|
||||
wagonCount: number;
|
||||
usedLengthMeters: number;
|
||||
/** Σ (tare + cargo). */
|
||||
usedGrossWeightTons: number;
|
||||
usedTareWeightTons: number;
|
||||
usedCargoWeightTons: number;
|
||||
remainingLengthMeters: number;
|
||||
remainingGrossWeightTons: number;
|
||||
remainingWagons: number;
|
||||
};
|
||||
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
|
||||
const DEFAULT_WAGON_LENGTH_M = 14;
|
||||
const DEFAULT_WAGON_CAPACITY_T = 70;
|
||||
/** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */
|
||||
const DEFAULT_WAGON_TARE_T = 22.4;
|
||||
|
||||
function num(value: unknown, fallback = 0): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
|
||||
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive train capacity from locomotive pull weight and train length.
|
||||
* Wagon count is NOT a fixed 53 — it is the minimum of:
|
||||
* - floor(maxLength / shortest wagon type length)
|
||||
* - floor(maxWeight / lightest wagon type capacity)
|
||||
* Hard caps for a train: the locomotive's own limits, floored by the global rule
|
||||
* caps, then widened by the locomotive's overage tolerance.
|
||||
*/
|
||||
export function trainHardCaps(
|
||||
locomotive: LocomotiveLimits,
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): { maxWeightTons: number; maxLengthMeters: number } {
|
||||
const overageTons = num(locomotive.overageToleranceTons);
|
||||
const overageMeters = num(locomotive.overageToleranceMeters);
|
||||
|
||||
const weight =
|
||||
Math.min(
|
||||
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
) + overageTons;
|
||||
const length =
|
||||
Math.min(
|
||||
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
) + overageMeters;
|
||||
|
||||
return {
|
||||
maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT,
|
||||
maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the planning capacity of a train from its locomotive.
|
||||
*
|
||||
* `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within
|
||||
* the train-length cap — the optimistic slot count, since a mixed consist of
|
||||
* longer wagons will hit the length cap sooner. It is deliberately NOT reduced by
|
||||
* weight: with no bookings yet there is no cargo, and assuming every wagon rides
|
||||
* at full rated payload would report 37 NW5 slots where the railway marshals 53.
|
||||
* Weight is enforced by {@link consistUsage} / {@link consistViolations} against
|
||||
* the cargo actually allocated.
|
||||
*/
|
||||
export function deriveTrainCapacityFromLocomotive(
|
||||
locomotive: LocomotiveLimits,
|
||||
wagonTypes: WagonTypeDimensions[],
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): DerivedTrainCapacity {
|
||||
const overageTons = Number(locomotive.overageToleranceTons) || 0;
|
||||
const overageMeters = Number(locomotive.overageToleranceMeters) || 0;
|
||||
const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps);
|
||||
|
||||
const maxWeightTons =
|
||||
Math.min(
|
||||
Number(locomotive.maxPullWeightTons) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
) + overageTons;
|
||||
const maxLengthMeters =
|
||||
Math.min(
|
||||
Number(locomotive.maxTrainLengthMeters) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
) + overageMeters;
|
||||
const lengths = wagonTypes
|
||||
.map((w) => num(w.lengthMeters))
|
||||
.filter((l) => l > 0);
|
||||
const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M;
|
||||
|
||||
const types =
|
||||
wagonTypes.length > 0
|
||||
? wagonTypes
|
||||
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
|
||||
const maxWagonSlots =
|
||||
minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0;
|
||||
|
||||
const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M));
|
||||
const minCapacity = Math.min(
|
||||
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
|
||||
);
|
||||
return { maxWeightTons, maxLengthMeters, maxWagonSlots };
|
||||
}
|
||||
|
||||
const byLength =
|
||||
minLength > 0 && Number.isFinite(maxLengthMeters)
|
||||
? Math.floor(maxLengthMeters / minLength)
|
||||
: 0;
|
||||
const byWeight =
|
||||
minCapacity > 0 && Number.isFinite(maxWeightTons)
|
||||
? Math.floor(maxWeightTons / minCapacity)
|
||||
: byLength;
|
||||
/**
|
||||
* What a real, mixed-type consist uses on all three axes, and what is left.
|
||||
* Every wagon contributes its own length and its own tare — no averaging over a
|
||||
* representative wagon type.
|
||||
*/
|
||||
export function consistUsage(
|
||||
slots: ConsistSlot[],
|
||||
caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number },
|
||||
): ConsistUsage {
|
||||
let usedLengthMeters = 0;
|
||||
let usedTareWeightTons = 0;
|
||||
let usedCargoWeightTons = 0;
|
||||
|
||||
const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight));
|
||||
for (const slot of slots) {
|
||||
usedLengthMeters += num(slot.lengthMeters);
|
||||
usedTareWeightTons += num(slot.tareWeightTons);
|
||||
usedCargoWeightTons += num(slot.cargoTons);
|
||||
}
|
||||
|
||||
const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons;
|
||||
|
||||
return {
|
||||
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
|
||||
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
|
||||
maxWagonSlots,
|
||||
wagonCount: slots.length,
|
||||
usedLengthMeters: round3(usedLengthMeters),
|
||||
usedGrossWeightTons: round3(usedGrossWeightTons),
|
||||
usedTareWeightTons: round3(usedTareWeightTons),
|
||||
usedCargoWeightTons: round3(usedCargoWeightTons),
|
||||
remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters),
|
||||
remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons),
|
||||
remainingWagons: caps.maxWagonSlots - slots.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */
|
||||
export function consistViolations(
|
||||
slots: ConsistSlot[],
|
||||
caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number },
|
||||
): string[] {
|
||||
const usage = consistUsage(slots, caps);
|
||||
const violations: string[] = [];
|
||||
|
||||
if (usage.usedGrossWeightTons > caps.maxWeightTons) {
|
||||
violations.push(
|
||||
`Total train gross weight ${usage.usedGrossWeightTons}T ` +
|
||||
`(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` +
|
||||
`exceeds max pull weight ${round3(caps.maxWeightTons)}T`,
|
||||
);
|
||||
}
|
||||
if (usage.usedLengthMeters > caps.maxLengthMeters) {
|
||||
violations.push(
|
||||
`Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`,
|
||||
);
|
||||
}
|
||||
if (usage.wagonCount > caps.maxWagonSlots) {
|
||||
violations.push(
|
||||
`Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`,
|
||||
);
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
function round3(value: number): number {
|
||||
return Number.isFinite(value) ? Number(value.toFixed(3)) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective pull limits for a train set with multiple locomotives: the weakest
|
||||
@@ -92,18 +222,14 @@ export function minLocomotiveLimits(
|
||||
if (!locomotives.length) return null;
|
||||
return {
|
||||
maxPullWeightTons: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
|
||||
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
|
||||
),
|
||||
maxTrainLengthMeters: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
|
||||
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
|
||||
),
|
||||
// Weakest locomotive's tolerance governs the set, same as its caps.
|
||||
overageToleranceTons: Math.min(
|
||||
...locomotives.map((l) => Number(l.overageToleranceTons) || 0),
|
||||
),
|
||||
overageToleranceMeters: Math.min(
|
||||
...locomotives.map((l) => Number(l.overageToleranceMeters) || 0),
|
||||
),
|
||||
overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))),
|
||||
overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,12 +243,27 @@ export function bookingTrainLengthMeters(
|
||||
return wagonCount * perWagon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gross weight a booking adds to its train: its cargo plus the tare of every
|
||||
* wagon it occupies. A booking is never weightless just because it is light —
|
||||
* the empty wagons still have to be pulled.
|
||||
*/
|
||||
export function bookingGrossWeightTons(
|
||||
cargoTons: number,
|
||||
wagonCount: number,
|
||||
tarePerWagonTons: number,
|
||||
): number {
|
||||
return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons));
|
||||
}
|
||||
|
||||
export function wagonTypeDimensionsFromEntity(wt: {
|
||||
lengthMeters?: number | string | null;
|
||||
capacityTons?: number | string | null;
|
||||
tareWeightTons?: number | string | null;
|
||||
}): WagonTypeDimensions {
|
||||
return {
|
||||
lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
||||
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
||||
lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
||||
capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
||||
tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm';
|
||||
import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -43,8 +43,6 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all
|
||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||
@@ -104,10 +102,13 @@ import {
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
minLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
WagonTypeDimensions,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
} from './booking-batch.constants';
|
||||
import {
|
||||
computeExportWindowTimes,
|
||||
@@ -3127,16 +3128,27 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
private async loadSchedulingWagonTypeDimensions(): Promise<
|
||||
Array<{ lengthMeters: number; capacityTons: number }>
|
||||
> {
|
||||
const types = await this.dataSource.getRepository(WagonType).find({
|
||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
||||
});
|
||||
/**
|
||||
* Every active wagon type: the slot count derives from the shortest wagon the
|
||||
* fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at
|
||||
* 12.228m) and under-report how many wagons the train length allows.
|
||||
*/
|
||||
private async loadSchedulingWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
|
||||
const types = await this.dataSource
|
||||
.getRepository(WagonType)
|
||||
.find({ where: { isActive: true } });
|
||||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||||
return [
|
||||
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
|
||||
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
|
||||
{
|
||||
lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
capacityTons: 70,
|
||||
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
},
|
||||
{
|
||||
lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
capacityTons: 60,
|
||||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3446,37 +3458,6 @@ export class TrainSchedulingService {
|
||||
return wagonType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft wagon-type resolution for the customer-facing availability preview
|
||||
* (getAvailableDaysForCargo). Reads the configured FK by cargo/container type;
|
||||
* returns null (→ "no days") instead of throwing when nothing is configured,
|
||||
* since this only estimates which days have wagons and creates no booking.
|
||||
*/
|
||||
private async resolveWagonTypeForPreview(
|
||||
freightType: 'CONTAINER' | 'BULK',
|
||||
cargoTypeCode: string | null,
|
||||
): Promise<WagonType | null> {
|
||||
if (freightType === 'BULK') {
|
||||
if (!cargoTypeCode) return null;
|
||||
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||||
where: { code: cargoTypeCode },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
return cargoType?.wagonType?.isActive ? cargoType.wagonType : null;
|
||||
}
|
||||
|
||||
// Container preview: the input carries no specific container type, so use the
|
||||
// wagon type of the first configured (active) container type.
|
||||
const containerType = await this.dataSource
|
||||
.getRepository(ContainerType)
|
||||
.findOne({
|
||||
where: { isActive: true, wagonTypeId: Not(IsNull()) },
|
||||
relations: { wagonType: true },
|
||||
order: { displayOrder: 'ASC' },
|
||||
});
|
||||
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp each plan slot with the leg it occupies (dynamic consist): the
|
||||
* boarding/alighting yards of the bookings it carries. Null means the
|
||||
@@ -4237,13 +4218,14 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given
|
||||
* cargo. A day is selectable only when ≥1 OPEN schedule on the route that day
|
||||
* has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that
|
||||
* schedule's origin yard, and (b) remaining train capacity (not fully
|
||||
* allocated). Days with trains but not enough matching wagons are excluded.
|
||||
* Same `{ days: string[] }` shape as getAvailableDays — the customer still
|
||||
* picks a DAY, not a train.
|
||||
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
|
||||
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
|
||||
* train capacity (not fully allocated). Wagon availability is deliberately NOT
|
||||
* checked here: whether a matching wagon currently sits in the right yard is an
|
||||
* operational question staff resolve when they approve or reject the booking,
|
||||
* not something the customer can act on while choosing a date. Same
|
||||
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
||||
* not a train.
|
||||
*/
|
||||
async getAvailableDaysForCargo(input: {
|
||||
originYardId?: string;
|
||||
@@ -4259,85 +4241,17 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (schedules.length === 0) return { days: [] };
|
||||
|
||||
// Resolve the wagon type this cargo needs via the cargo/container-type FK.
|
||||
// Soft (customer availability preview): no days if unresolved, never throws.
|
||||
const requiredType = await this.resolveWagonTypeForPreview(
|
||||
input.freightType,
|
||||
input.cargoTypeCode ?? null,
|
||||
);
|
||||
if (!requiredType) return { days: [] };
|
||||
|
||||
// How many wagons of that type the cargo needs.
|
||||
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
|
||||
void slotsNeeded; // TEMP: unused while the wagon-availability filter is off.
|
||||
|
||||
// TEMP (per request): wagon-availability filtering is DISABLED. A day is now
|
||||
// offered whenever a bookable schedule that day has remaining train capacity
|
||||
// — regardless of whether matching wagons are actually available at the
|
||||
// origin / boarding yard. This surfaces days even when no wagon is on hand.
|
||||
// Restore the block below to bring back the "enough matching wagons" gate.
|
||||
//
|
||||
// // AVAILABLE wagons of the required type, counted once per origin yard.
|
||||
// const availableByYard = new Map<string, number>();
|
||||
// const availableAt = async (yardId: string): Promise<number> => {
|
||||
// const cached = availableByYard.get(yardId);
|
||||
// if (cached !== undefined) return cached;
|
||||
// const counts = await this.countFleetAvailability(yardId);
|
||||
// const n =
|
||||
// counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
// availableByYard.set(yardId, n);
|
||||
// return n;
|
||||
// };
|
||||
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const hasCapacity =
|
||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||
if (!hasCapacity) continue;
|
||||
// TEMP (per request): wagon-availability check commented out — see note
|
||||
// above. Dynamic consist: wagons may ride from the train's origin OR
|
||||
// already sit at the booking's own boarding yard and attach when the train
|
||||
// arrives — either pool can serve a sub-corridor booking.
|
||||
// let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||
// if (
|
||||
// !enoughWagons &&
|
||||
// input.originYardId &&
|
||||
// input.originYardId !== s.originStationId
|
||||
// ) {
|
||||
// enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded;
|
||||
// }
|
||||
// if (!enoughWagons) continue;
|
||||
if (s.scheduledDepartureDate)
|
||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||
}
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight /
|
||||
* capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per
|
||||
* wagon. Mirrors wagon-plan.util without fabricating Booking entities.
|
||||
*/
|
||||
private wagonsNeededForCargo(
|
||||
input: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
totalWeightTons?: number;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
},
|
||||
wagonType: WagonType,
|
||||
): number {
|
||||
if (input.freightType === 'BULK') {
|
||||
const capacity = Number(wagonType.capacityTons) || 1;
|
||||
const weight = Number(input.totalWeightTons ?? 0);
|
||||
return Math.max(1, Math.ceil(weight / capacity));
|
||||
}
|
||||
const teu = (input.containers ?? []).reduce((sum, c) => {
|
||||
const per = c.containerSize === '40ft' ? 2 : 1;
|
||||
return sum + per * Math.max(0, Number(c.quantity ?? 0));
|
||||
}, 0);
|
||||
return Math.max(1, Math.ceil(teu / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { consistViolations } from './train-capacity.util';
|
||||
|
||||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||
@@ -35,6 +36,9 @@ export type WagonPlanSlot = {
|
||||
wagonTypeCode: string;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
/** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */
|
||||
tareWeightTons: number;
|
||||
/** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */
|
||||
assignedWeightTons: number;
|
||||
allocations: WagonAllocationRecord[];
|
||||
slotLoadType?: SlotLoadType;
|
||||
@@ -78,6 +82,14 @@ export function roundTons(value: number | string | null | undefined): number {
|
||||
return Number(numericValue.toFixed(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill;
|
||||
* a missing tare must read as 0 rather than silently inventing dead weight.
|
||||
*/
|
||||
export function tareTonsOf(wagonType: Pick<WagonType, 'tareWeightTons'>): number {
|
||||
return roundTons(wagonType.tareWeightTons ?? 0);
|
||||
}
|
||||
|
||||
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
|
||||
export function teuSlotsForSizeFt(sizeFt: number): number {
|
||||
return sizeFt >= 40 ? 2 : 1;
|
||||
@@ -146,6 +158,7 @@ export function buildContainerWagonPlan(
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: Number(wagonType.capacityTons),
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(wagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
}));
|
||||
@@ -175,6 +188,7 @@ export function buildBulkWagonPlan(
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: capacity,
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(wagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
}));
|
||||
@@ -214,6 +228,7 @@ export function buildMixedWagonPlan(
|
||||
wagonTypeCode: containerWagonType.code,
|
||||
capacityTons: Number(containerWagonType.capacityTons),
|
||||
lengthMeters: Number(containerWagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(containerWagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
slotLoadType: 'CONTAINER',
|
||||
@@ -443,47 +458,46 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a consist against its train's three limits. Weight is GROSS — every slot
|
||||
* contributes its own tare plus the cargo assigned to it — because the locomotive
|
||||
* pull limit governs what it drags, not what was sold. Length and tare are summed
|
||||
* per slot, so a mixed consist is measured as it actually stands rather than
|
||||
* through one representative wagon type.
|
||||
*
|
||||
* `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain`
|
||||
* is absent; slot dimensions always win over it.
|
||||
*/
|
||||
export function validateTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonType: Pick<WagonType, 'lengthMeters'>,
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.floor(maxLengthMeters / wagonLength);
|
||||
const maxWagonSlots =
|
||||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength);
|
||||
|
||||
const totalWeightTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
|
||||
const violations = consistViolations(
|
||||
wagonPlan.map((slot) => ({
|
||||
lengthMeters: Number(slot.lengthMeters),
|
||||
tareWeightTons: Number(slot.tareWeightTons ?? 0),
|
||||
cargoTons: Number(slot.assignedWeightTons),
|
||||
})),
|
||||
{ maxWeightTons, maxLengthMeters, maxWagonSlots },
|
||||
);
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
|
||||
if (totalWeightTons > maxWeightTons) {
|
||||
violations.push(
|
||||
`Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`,
|
||||
);
|
||||
}
|
||||
if (totalLengthMeters > maxLengthMeters) {
|
||||
violations.push(
|
||||
`Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
if (wagonPlan.length > maxWagonsPerTrain) {
|
||||
violations.push(
|
||||
`Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`,
|
||||
);
|
||||
}
|
||||
|
||||
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixed consist: the wagon-count fallback uses the shortest type present, since
|
||||
* that is the most wagons that could ever fit. Weight and length still come from
|
||||
* the slots themselves.
|
||||
*/
|
||||
export function validateMixedTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: WagonType[],
|
||||
|
||||
@@ -56,6 +56,17 @@ export class CreateWagonTypeDto {
|
||||
@Min(0.001)
|
||||
lengthMeters!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Empty (unladen) wagon weight in metric tons. Required: the locomotive pull ' +
|
||||
'limit applies to gross weight (tare + cargo), so capacity cannot be computed without it.',
|
||||
example: 22.4,
|
||||
})
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
tareWeightTons!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Supported load types, e.g. CONTAINER,BULK',
|
||||
type: [String],
|
||||
|
||||
@@ -28,8 +28,9 @@ export class WagonType extends BaseEntity {
|
||||
@Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
equatedLengthM?: number | null;
|
||||
|
||||
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
tareWeightTons?: number | null;
|
||||
/** Empty wagon weight. Required: the locomotive's pull limit is a gross limit. */
|
||||
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
tareWeightTons!: number;
|
||||
|
||||
@Column({ name: 'supports_container', type: 'boolean', default: false })
|
||||
supportsContainer!: boolean;
|
||||
|
||||
@@ -80,6 +80,7 @@ export class WagonTypesService {
|
||||
name: dto.name.trim(),
|
||||
capacityTons: dto.capacityTons,
|
||||
lengthMeters: dto.lengthMeters,
|
||||
tareWeightTons: dto.tareWeightTons ?? null,
|
||||
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
|
||||
@@ -307,13 +307,23 @@ const RuleEngineFormDialog = ({
|
||||
);
|
||||
}
|
||||
|
||||
const isNumber = field.type === "number";
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={label}
|
||||
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
||||
type={isNumber ? "number" : field.type === "date" ? "date" : "text"}
|
||||
// Every rule-engine number (sizes, capacities, counts, points, rates,
|
||||
// display order) is a non-negative magnitude — reject negatives outright
|
||||
// rather than letting a typed "-" reach the API.
|
||||
min={isNumber ? 0 : undefined}
|
||||
value={String(values[field.name] ?? "")}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
if (isNumber && next.trim().startsWith("-")) return;
|
||||
setField(field.name, next);
|
||||
}}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
size="md"
|
||||
|
||||
@@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({
|
||||
}
|
||||
min={1}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
) : (
|
||||
@@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({
|
||||
}
|
||||
min={0}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function DurationField({
|
||||
emitNative(v === "" ? "" : Number(v), unit)
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={min != null ? convert(min, nativeUnit, unit) : 0}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -536,6 +536,7 @@ export function WagonTypesCrudPage() {
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
tareWeightTons: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -586,6 +587,7 @@ export function WagonTypesCrudPage() {
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
tareWeightTons: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -599,6 +601,7 @@ export function WagonTypesCrudPage() {
|
||||
name: type.name ?? '',
|
||||
capacityTons: type.capacityTons ?? 0,
|
||||
lengthMeters: type.lengthMeters ?? 0,
|
||||
tareWeightTons: type.tareWeightTons ?? '',
|
||||
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
|
||||
isActive: type.isActive,
|
||||
});
|
||||
@@ -607,10 +610,17 @@ export function WagonTypesCrudPage() {
|
||||
|
||||
const validateWagonType = () => {
|
||||
const errors: Record<string, string> = {};
|
||||
// normalizePayload strips empty strings, so a blank numeric field would be
|
||||
// dropped from the payload rather than rejected. Each must be a positive
|
||||
// number here — the API's @Min(0.001) agrees.
|
||||
const positive = (value: FormValue) => Number.isFinite(Number(value)) && Number(value) > 0;
|
||||
|
||||
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
|
||||
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
|
||||
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
|
||||
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
|
||||
if (!positive(form.capacityTons)) errors.capacityTons = 'Capacity must be greater than 0';
|
||||
if (!positive(form.lengthMeters)) errors.lengthMeters = 'Length must be greater than 0';
|
||||
if (!positive(form.tareWeightTons))
|
||||
errors.tareWeightTons = 'Tare weight is required and must be greater than 0';
|
||||
return errors;
|
||||
};
|
||||
|
||||
@@ -707,6 +717,7 @@ export function WagonTypesCrudPage() {
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>Length (m)</MantineTable.Th>
|
||||
<MantineTable.Th>Tare weight (tons)</MantineTable.Th>
|
||||
<MantineTable.Th>Load types</MantineTable.Th>
|
||||
<MantineTable.Th>Status</MantineTable.Th>
|
||||
<MantineTable.Th ta="right">Actions</MantineTable.Th>
|
||||
@@ -719,6 +730,7 @@ export function WagonTypesCrudPage() {
|
||||
<MantineTable.Td>{type.name}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.tareWeightTons ?? '-'}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
|
||||
<MantineTable.Td>
|
||||
<MantineBadge color={type.isActive === false ? 'gray' : 'edr-green'} variant="light">
|
||||
@@ -747,7 +759,7 @@ export function WagonTypesCrudPage() {
|
||||
))}
|
||||
{!query.isLoading && filtered.length === 0 ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<MantineTable.Td colSpan={8}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
No wagon types found.
|
||||
</Text>
|
||||
@@ -756,7 +768,7 @@ export function WagonTypesCrudPage() {
|
||||
) : null}
|
||||
{query.isLoading ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<MantineTable.Td colSpan={8}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
Loading...
|
||||
</Text>
|
||||
@@ -811,6 +823,15 @@ export function WagonTypesCrudPage() {
|
||||
error={fieldErrors.lengthMeters}
|
||||
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Tare weight (tons)"
|
||||
description="Empty wagon weight — counts against the locomotive's pull limit alongside the cargo"
|
||||
required
|
||||
min={0}
|
||||
value={form.tareWeightTons === '' || form.tareWeightTons == null ? '' : Number(form.tareWeightTons)}
|
||||
error={fieldErrors.tareWeightTons}
|
||||
onChange={(value) => setForm((current) => ({ ...current, tareWeightTons: value }))}
|
||||
/>
|
||||
<MantineSelect
|
||||
label="Status"
|
||||
value={form.isActive ? 'true' : 'false'}
|
||||
@@ -1109,6 +1130,16 @@ export function LocomotivesCrudPage() {
|
||||
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
|
||||
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
|
||||
{
|
||||
key: 'overageToleranceTons',
|
||||
label: 'Weight tolerance (t)',
|
||||
render: (locomotive) => locomotive.overageToleranceTons ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'overageToleranceMeters',
|
||||
label: 'Length tolerance (m)',
|
||||
render: (locomotive) => locomotive.overageToleranceMeters ?? '-',
|
||||
},
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
@@ -1137,6 +1168,11 @@ export function LocomotivesCrudPage() {
|
||||
},
|
||||
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
||||
// Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2
|
||||
// wagons gross 3,522.4T against a 3,500T pull limit and only board because
|
||||
// of the weight tolerance.
|
||||
{ key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' },
|
||||
{ key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' },
|
||||
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
|
||||
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
||||
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
||||
@@ -1148,6 +1184,8 @@ export function LocomotivesCrudPage() {
|
||||
status: 'AVAILABLE',
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
overageToleranceTons: '',
|
||||
overageToleranceMeters: '',
|
||||
powerKw: '',
|
||||
tractionForceKn: '',
|
||||
maxSpeedKmh: '',
|
||||
|
||||
@@ -158,6 +158,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
{ id: "overageToleranceTons", header: "Weight tolerance (t)", accessorKey: "overageToleranceTons", format: "number" },
|
||||
{ id: "overageToleranceMeters", header: "Length tolerance (m)", accessorKey: "overageToleranceMeters", format: "number" },
|
||||
],
|
||||
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
|
||||
formFields: [
|
||||
@@ -167,6 +169,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
|
||||
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
|
||||
// Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2
|
||||
// wagons gross 3,522.4T against a 3,500T pull limit and only board because
|
||||
// of the weight tolerance.
|
||||
{ name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" },
|
||||
{ name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" },
|
||||
{ name: "powerKw", label: "Power (kW)", type: "number" },
|
||||
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
|
||||
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
|
||||
@@ -178,6 +185,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
currentYardId: "",
|
||||
maxPullWeightTons: 2500,
|
||||
maxTrainLengthMeters: 760,
|
||||
overageToleranceTons: "",
|
||||
overageToleranceMeters: "",
|
||||
powerKw: "",
|
||||
tractionForceKn: "",
|
||||
maxSpeedKmh: "",
|
||||
|
||||
@@ -175,6 +175,7 @@ function CapacityChip({
|
||||
);
|
||||
}
|
||||
|
||||
/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */
|
||||
function weightPctOf(s: BatchBoardSchedule) {
|
||||
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
|
||||
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
|
||||
@@ -185,6 +186,11 @@ function lengthPctOf(s: BatchBoardSchedule) {
|
||||
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
|
||||
: null;
|
||||
}
|
||||
function wagonPctOf(s: BatchBoardSchedule) {
|
||||
return s.capacity.maxWagons && s.capacity.maxWagons > 0
|
||||
? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100
|
||||
: null;
|
||||
}
|
||||
|
||||
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
const navigate = useNavigate();
|
||||
@@ -192,6 +198,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
|
||||
const lengthPct = lengthPctOf(schedule);
|
||||
const weightPct = weightPctOf(schedule);
|
||||
const wagonPct = wagonPctOf(schedule);
|
||||
|
||||
const totalBookings = totalBookingCount(counts);
|
||||
|
||||
@@ -275,7 +282,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* capacity: weight + length rings + wagons (numbers preserved) */}
|
||||
{/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */}
|
||||
<Box
|
||||
py="sm"
|
||||
px="xs"
|
||||
@@ -289,26 +296,35 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
{weightPct != null ? (
|
||||
<CapacityRing
|
||||
pct={weightPct}
|
||||
label="WEIGHT"
|
||||
label="GROSS WT"
|
||||
current={fmtTons(capacity.usedWeightTons)}
|
||||
max={fmtTons(capacity.maxWeightTons ?? 0)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Stack gap={0} align="center" style={{ flex: 1 }}>
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="gray">
|
||||
<Package size={17} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
allocated
|
||||
</Text>
|
||||
</Stack>
|
||||
{wagonPct != null ? (
|
||||
<CapacityRing
|
||||
pct={wagonPct}
|
||||
label="WAGONS"
|
||||
current={String(capacity.allocatedWagons)}
|
||||
max={String(capacity.maxWagons ?? 0)}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap={0} align="center" style={{ flex: 1 }}>
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="gray">
|
||||
<Package size={17} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
allocated
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{lengthPct != null ? (
|
||||
<CapacityRing
|
||||
@@ -519,30 +535,42 @@ export default function BatchBoardPage() {
|
||||
id: "capacity",
|
||||
header: "Capacity",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
|
||||
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
border: "1px solid var(--mantine-color-edr-green-1)",
|
||||
}}
|
||||
>
|
||||
<Package size={12} color="var(--mantine-color-edr-green-7)" />
|
||||
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
|
||||
{row.original.capacity.allocatedWagons}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" lh={1.2}>
|
||||
wgn
|
||||
</Text>
|
||||
cell: ({ row }) => {
|
||||
const { allocatedWagons, maxWagons } = row.original.capacity;
|
||||
const wagonPct = wagonPctOf(row.original);
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="gross" />
|
||||
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
|
||||
{wagonPct != null ? (
|
||||
<CapacityChip
|
||||
icon={Package}
|
||||
pct={wagonPct}
|
||||
text={`${allocatedWagons}/${maxWagons} wgn`}
|
||||
/>
|
||||
) : (
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 8,
|
||||
background: "var(--mantine-color-edr-green-0)",
|
||||
border: "1px solid var(--mantine-color-edr-green-1)",
|
||||
}}
|
||||
>
|
||||
<Package size={12} color="var(--mantine-color-edr-green-7)" />
|
||||
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
|
||||
{allocatedWagons}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" lh={1.2}>
|
||||
wgn
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bookings",
|
||||
|
||||
@@ -892,7 +892,9 @@ export default function BatchScheduleDetailPage() {
|
||||
items={[
|
||||
{
|
||||
label: "Allocated wagons",
|
||||
value: data.capacity.allocatedWagons,
|
||||
value: data.capacity.maxWagons
|
||||
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
|
||||
: data.capacity.allocatedWagons,
|
||||
hint: "on this train",
|
||||
icon: Boxes,
|
||||
},
|
||||
@@ -904,10 +906,11 @@ export default function BatchScheduleDetailPage() {
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
label: "Weight",
|
||||
label: "Gross weight",
|
||||
value: data.capacity.maxWeightTons
|
||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||
: fmtTons(data.capacity.usedWeightTons),
|
||||
hint: "wagon tare + cargo",
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -107,6 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -119,6 +120,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -130,6 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -145,6 +148,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
@@ -160,6 +164,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
disabled={loading}
|
||||
@@ -203,6 +208,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={23}
|
||||
@@ -216,6 +222,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
setForm((current) => ({ ...current, windowCloseHour: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={23}
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface Locomotive {
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
/** Tons a train may exceed maxPullWeightTons by before scheduling blocks it. */
|
||||
overageToleranceTons?: number | null;
|
||||
/** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */
|
||||
overageToleranceMeters?: number | null;
|
||||
powerKw?: number | null;
|
||||
tractionForceKn?: number | null;
|
||||
maxSpeedKmh?: number | null;
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface WagonType {
|
||||
name: string;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
/** Empty wagon weight; required, since pull limits apply to tare + cargo. */
|
||||
tareWeightTons: number;
|
||||
supportedLoadTypes: string[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -280,10 +280,13 @@ export interface BatchBoardSchedule {
|
||||
capacity: {
|
||||
allocatedWagons: number;
|
||||
allocatedLengthMeters: number;
|
||||
/** Train-length cap: locomotive floored by global rules, plus overage tolerance. */
|
||||
maxLengthMeters: number | null;
|
||||
/** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */
|
||||
usedWeightTons: number;
|
||||
/** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */
|
||||
maxWeightTons: number | null;
|
||||
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
||||
/** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */
|
||||
maxWagons: number | null;
|
||||
};
|
||||
counts: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, type KeyboardEvent } from "react";
|
||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, Package, Plus, Snowflake, Trash2, Weight } from "lucide-react";
|
||||
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
|
||||
@@ -26,6 +26,15 @@ type BookingForm = UseFormReturn<
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
/**
|
||||
* Every quantity on this step is a non-negative magnitude. A native number
|
||||
* input's `min` only constrains its stepper, so swallow the minus key before it
|
||||
* can put a negative into the field at all.
|
||||
*/
|
||||
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "-") event.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* One numbered toggle per container unit in the line — tap units to mark how
|
||||
* many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
|
||||
@@ -379,6 +388,7 @@ export function Step5CargoDetails({
|
||||
}}
|
||||
id="cargoWeight"
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"}
|
||||
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
|
||||
leftSection={
|
||||
@@ -435,6 +445,7 @@ export function Step5CargoDetails({
|
||||
render={({ field: hq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
size="sm"
|
||||
label={
|
||||
isPerItem
|
||||
@@ -483,6 +494,7 @@ export function Step5CargoDetails({
|
||||
render={({ field: rq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
size="sm"
|
||||
label={
|
||||
isPerItem
|
||||
@@ -630,6 +642,7 @@ export function Step5CargoDetails({
|
||||
}}
|
||||
onBlur={qtyField.onBlur}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
min={1}
|
||||
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -58,6 +58,15 @@ type ShipmentForm = ReturnType<
|
||||
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Every quantity on this form is a non-negative magnitude. A native number
|
||||
* input's `min` only constrains its stepper, so swallow the minus key before it
|
||||
* can put a negative into the field at all.
|
||||
*/
|
||||
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "-") event.preventDefault();
|
||||
};
|
||||
|
||||
export default function NewShipmentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -962,6 +971,7 @@ function CargoStep({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity (tons)"
|
||||
placeholder="e.g. 1200"
|
||||
min={0}
|
||||
@@ -979,6 +989,7 @@ function CargoStep({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Item count (if applicable)"
|
||||
placeholder="e.g. 500"
|
||||
min={0}
|
||||
@@ -997,6 +1008,7 @@ function CargoStep({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Hazardous quantity"
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -1015,6 +1027,7 @@ function CargoStep({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Refrigerated quantity"
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -1093,6 +1106,7 @@ function ContainerLineEditor({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
error={fieldState.error?.message}
|
||||
@@ -1113,6 +1127,7 @@ function ContainerLineEditor({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Hazardous qty"
|
||||
min={0}
|
||||
error={fieldState.error?.message}
|
||||
@@ -1130,6 +1145,7 @@ function ContainerLineEditor({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
error={fieldState.error?.message}
|
||||
@@ -1182,6 +1198,7 @@ function ContainerLineEditor({
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label={u === 0 ? "VGM (tons) *" : undefined}
|
||||
placeholder="e.g. 24.5"
|
||||
min={0}
|
||||
|
||||
@@ -110,7 +110,13 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
});
|
||||
if (ctx.isHazardous) {
|
||||
const h = Number(line.hazardousQuantity || 0);
|
||||
if (h > qty) {
|
||||
if (h < 0) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "hazardousQuantity"],
|
||||
message: "Enter a valid hazardous quantity.",
|
||||
});
|
||||
} else if (h > qty) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "hazardousQuantity"],
|
||||
@@ -120,7 +126,13 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
}
|
||||
if (ctx.isReefer) {
|
||||
const r = Number(line.reeferQuantity || 0);
|
||||
if (r > qty) {
|
||||
if (r < 0) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "reeferQuantity"],
|
||||
message: "Enter a valid refrigerated quantity.",
|
||||
});
|
||||
} else if (r > qty) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", i, "reeferQuantity"],
|
||||
@@ -130,10 +142,21 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const bulkCap =
|
||||
ctx.unitOfMeasure === "PER_ITEM"
|
||||
? Number(data.itemCount || 0)
|
||||
: Number(data.cargoWeightTons || 0);
|
||||
const isPerItem = ctx.unitOfMeasure === "PER_ITEM";
|
||||
const bulkCap = isPerItem
|
||||
? Number(data.itemCount || 0)
|
||||
: Number(data.cargoWeightTons || 0);
|
||||
|
||||
// The bulk cargo amount itself: a positive magnitude. Without this a
|
||||
// negative (typed past the input's `min`) reaches the API unchecked.
|
||||
const bulkPath = isPerItem ? "itemCount" : "cargoWeightTons";
|
||||
if (Number.isNaN(bulkCap) || bulkCap <= 0) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: [bulkPath],
|
||||
message: "Enter a quantity greater than 0.",
|
||||
});
|
||||
}
|
||||
|
||||
const boundBulkPortion = (
|
||||
on: boolean,
|
||||
|
||||
Reference in New Issue
Block a user