mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
feat: enhance train scheduling and contract management features
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage. - Implemented API endpoints for recording station work and managing wagon detach requests. - Updated contract templates to include Ethiopian customs handling options. - Enhanced shipment forms to collect customs clearing agent details for without-customs bookings. - Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts. - Improved validation for customs clearing agent information in shipment forms. - Updated various components and services to accommodate new features and ensure data integrity.
This commit is contained in:
@@ -76,7 +76,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
sizePartialOfferWagons,
|
||||
@@ -2884,7 +2884,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.map((o) => ({
|
||||
...o,
|
||||
free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0,
|
||||
takePerWagon: bulkTonsPerWagon(
|
||||
takePerWagon: bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
o.wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
@@ -4722,7 +4723,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const cargoTons = bookingCargoTons(booking);
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon), so divide by the cap where one is configured for this type.
|
||||
const tonsPerWagon = bulkTonsPerWagon(
|
||||
const tonsPerWagon = bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
booking.cargoType?.wagonTypes?.[0]?.id,
|
||||
capacityTons,
|
||||
@@ -4798,7 +4800,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const wagonTypeId = o.wagonTypeId as string;
|
||||
// Each type sized on its OWN per-wagon tonnage cap, not just its rating
|
||||
// — a type capped lower swallows less per wagon.
|
||||
const tonsPerWagon = bulkTonsPerWagon(
|
||||
const tonsPerWagon = bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
@@ -5210,7 +5213,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.map((o) => ({
|
||||
...o,
|
||||
free: stock.availableFor([o.wagonTypeId], leg),
|
||||
takePerWagon: bulkTonsPerWagon(
|
||||
takePerWagon: bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
o.wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
|
||||
@@ -18,7 +18,11 @@ import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import {
|
||||
StationWorkLog,
|
||||
StationWorkPhaseLog,
|
||||
TrainSchedule,
|
||||
} from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
@@ -77,6 +81,7 @@ export class BookingJourneyService {
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
this.assertStationWorkStarted(schedule, booking.originYardId, 'loading');
|
||||
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
||||
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||
// however it arrived and whatever it is allocated to.
|
||||
@@ -166,6 +171,7 @@ export class BookingJourneyService {
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
|
||||
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
||||
|
||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||
@@ -294,6 +300,9 @@ export class BookingJourneyService {
|
||||
// dispatch — assertTrainAtYard allows origin loading in that state, so
|
||||
// the UI position must agree or origin Load buttons grey out wrongly.
|
||||
trainAtYardId: latest?.yardId ?? schedule.originStationId,
|
||||
// Per-yard loading/unloading time windows — the UI derives its
|
||||
// start/end buttons and the load/unload gating from these.
|
||||
stationWorkLogs: schedule.stationWorkLogs ?? {},
|
||||
yards: [...byYard.values()],
|
||||
};
|
||||
}
|
||||
@@ -414,6 +423,62 @@ export class BookingJourneyService {
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a station's loading/unloading time-window click (or edit it — an
|
||||
* explicit `at` on an already-set edge overwrites the timestamp under the
|
||||
* same permission that set it). Rules: end needs start, start ≤ end, no
|
||||
* future times. Stored as ISO strings in train_schedules.station_work_logs.
|
||||
* ponytail: read-modify-write on the jsonb — two operators clicking the same
|
||||
* schedule in the same instant can clobber one edge; move to jsonb_set if
|
||||
* that ever bites.
|
||||
*/
|
||||
async recordStationWork(
|
||||
scheduleId: string,
|
||||
yardId: string,
|
||||
phase: 'loading' | 'unloading',
|
||||
edge: 'start' | 'end',
|
||||
at?: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const when = at ? new Date(at) : new Date();
|
||||
if (Number.isNaN(when.getTime())) {
|
||||
throw new BadRequestException('Invalid timestamp');
|
||||
}
|
||||
if (when.getTime() > Date.now() + 60_000) {
|
||||
throw new BadRequestException(`${phase} ${edge} time cannot be in the future`);
|
||||
}
|
||||
|
||||
const logs: Record<string, StationWorkLog> = schedule.stationWorkLogs ?? {};
|
||||
const entry: StationWorkLog = logs[yardId] ?? {};
|
||||
const ph: StationWorkPhaseLog = entry[phase] ?? {};
|
||||
|
||||
if (edge === 'end') {
|
||||
if (!ph.startedAt) {
|
||||
throw new BadRequestException(`Start ${phase} at this station first`);
|
||||
}
|
||||
if (when.getTime() < new Date(ph.startedAt).getTime()) {
|
||||
throw new BadRequestException(`${phase} end cannot be before its start`);
|
||||
}
|
||||
ph.endedAt = when.toISOString();
|
||||
ph.endedByUserId = userId ?? null;
|
||||
} else {
|
||||
if (ph.endedAt && when.getTime() > new Date(ph.endedAt).getTime()) {
|
||||
throw new BadRequestException(`${phase} start cannot be after its end`);
|
||||
}
|
||||
ph.startedAt = when.toISOString();
|
||||
ph.startedByUserId = userId ?? null;
|
||||
}
|
||||
|
||||
entry[phase] = ph;
|
||||
logs[yardId] = entry;
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { stationWorkLogs: logs });
|
||||
|
||||
return { scheduleId, yardId, phase, ...ph };
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
@@ -481,6 +546,27 @@ export class BookingJourneyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading/unloading a booking is only allowed inside a started work window
|
||||
* at that yard — the operator must click "Start loading"/"Start unloading"
|
||||
* (recordStationWork) before touching cargo. The window's END is not checked:
|
||||
* a straggler booking can still be confirmed after the end click, and the
|
||||
* operator can push the end time later (it's editable) if that matters.
|
||||
* Lives here (not the controller) so the checkpoint-driven autoUnloadAtYard
|
||||
* path is gated too — the user wants unloading fully manual.
|
||||
*/
|
||||
private assertStationWorkStarted(
|
||||
schedule: TrainSchedule,
|
||||
yardId: string,
|
||||
phase: 'loading' | 'unloading',
|
||||
): void {
|
||||
if (!schedule.stationWorkLogs?.[yardId]?.[phase]?.startedAt) {
|
||||
throw new BadRequestException(
|
||||
`Start ${phase} at this station first — the ${phase} time window has not been started`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||
* or — for a booking boarding at the train's own origin — when the train has
|
||||
|
||||
@@ -18,14 +18,19 @@ import {
|
||||
TrainSchedulingCreate,
|
||||
TrainSchedulingEditTrainNumber,
|
||||
TrainSchedulingLoad,
|
||||
TrainSchedulingLoadingEnd,
|
||||
TrainSchedulingLoadingStart,
|
||||
TrainSchedulingReschedule,
|
||||
TrainSchedulingUnload,
|
||||
TrainSchedulingUnloadingEnd,
|
||||
TrainSchedulingUnloadingStart,
|
||||
TrainSchedulingRulesManage,
|
||||
TrainSchedulingUpdate,
|
||||
TrainSchedulingView,
|
||||
} from "../../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
|
||||
import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto";
|
||||
import { StationWorkDto } from "../dto/station-work.dto";
|
||||
import { AssignBookingsDto } from "../dto/assign-bookings.dto";
|
||||
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
|
||||
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
|
||||
@@ -614,6 +619,68 @@ export class TrainSchedulingController {
|
||||
return this.bookingJourneyService.listYardWork(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/stations/:yardId/loading/start")
|
||||
@TrainSchedulingLoadingStart()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Start (or correct, via `at`) this station's loading time window — required before bookings can be loaded there",
|
||||
})
|
||||
startStationLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("yardId", ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: StationWorkDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.recordStationWork(
|
||||
id, yardId, "loading", "start", dto.at, resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/stations/:yardId/loading/end")
|
||||
@TrainSchedulingLoadingEnd()
|
||||
@ApiOperation({ summary: "End (or correct, via `at`) this station's loading time window" })
|
||||
endStationLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("yardId", ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: StationWorkDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.recordStationWork(
|
||||
id, yardId, "loading", "end", dto.at, resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/stations/:yardId/unloading/start")
|
||||
@TrainSchedulingUnloadingStart()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Start (or correct, via `at`) this station's unloading time window — required before bookings can be unloaded there",
|
||||
})
|
||||
startStationUnloading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("yardId", ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: StationWorkDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.recordStationWork(
|
||||
id, yardId, "unloading", "start", dto.at, resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/stations/:yardId/unloading/end")
|
||||
@TrainSchedulingUnloadingEnd()
|
||||
@ApiOperation({ summary: "End (or correct, via `at`) this station's unloading time window" })
|
||||
endStationUnloading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("yardId", ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: StationWorkDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.recordStationWork(
|
||||
id, yardId, "unloading", "end", dto.at, resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/load")
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
/**
|
||||
* A station loading/unloading window click. `at` omitted = "now" (the button
|
||||
* click); `at` given = record or correct the timestamp after the fact — same
|
||||
* endpoint, same permission.
|
||||
*/
|
||||
export class StationWorkDto {
|
||||
@ApiPropertyOptional({ description: 'ISO timestamp; omitted = now. Never in the future.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
at?: string;
|
||||
}
|
||||
@@ -156,7 +156,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
consistViolations,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
combinedLocomotiveLimits,
|
||||
@@ -2884,6 +2884,25 @@ export class TrainSchedulingService {
|
||||
schedule = reloaded;
|
||||
}
|
||||
}
|
||||
// Loading is tracked per station: dispatching with cargo still to board at
|
||||
// the origin marks it loaded (checklist + auto-load below), so the origin's
|
||||
// loading time window must have been started first — same gate the
|
||||
// per-booking load endpoint enforces.
|
||||
const originBoarders = await this.unloadedOriginBoarderIds(
|
||||
scheduleId,
|
||||
schedule.originStationId,
|
||||
);
|
||||
const boardersToLoad = dto.loadedBookingIds
|
||||
? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id))
|
||||
: originBoarders;
|
||||
if (
|
||||
boardersToLoad.length &&
|
||||
!schedule.stationWorkLogs?.[schedule.originStationId]?.loading?.startedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Start loading at the origin station before dispatching with cargo to load',
|
||||
);
|
||||
}
|
||||
// Staff may record the departure after the fact — past is fine, future is not.
|
||||
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||
this.assertNotFuture(now, 'Departure time');
|
||||
@@ -4436,6 +4455,9 @@ export class TrainSchedulingService {
|
||||
origin: stations[0]?.label ?? null,
|
||||
destination: stations[stations.length - 1]?.label ?? null,
|
||||
stations,
|
||||
// Per-yard loading/unloading time windows for the track page's
|
||||
// start/end buttons and elapsed-time display.
|
||||
stationWorkLogs: schedule.stationWorkLogs ?? {},
|
||||
currentSequenceNo,
|
||||
checkpoints: events.map((e) => ({
|
||||
id: e.id,
|
||||
@@ -4852,6 +4874,23 @@ export class TrainSchedulingService {
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||||
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
||||
}
|
||||
// Arrival bulk-marks every booking destined for the final yard as arrived
|
||||
// (autoArriveAtFinalYard) — unloading is tracked per station, so the
|
||||
// destination's unloading time window must be started before that sweep
|
||||
// may run. Skipped when nothing on the train alights at the final yard.
|
||||
const alightsAtFinal = (schedule.scheduleBookings ?? []).some(
|
||||
(sb) =>
|
||||
sb.booking?.destinationYardId === schedule.destinationStationId &&
|
||||
sb.booking?.status === 'IN_TRANSIT',
|
||||
);
|
||||
if (
|
||||
alightsAtFinal &&
|
||||
!schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Start unloading at the destination station before marking the train arrived',
|
||||
);
|
||||
}
|
||||
|
||||
// The arrival clock: the operator's entered time when arriving via the final
|
||||
// checkpoint (already order/future-checked there), else now.
|
||||
@@ -9362,6 +9401,8 @@ export class TrainSchedulingService {
|
||||
Booking,
|
||||
| 'freightType'
|
||||
| 'cargoTotalWeightVgm'
|
||||
| 'bulkTotalWeightTons'
|
||||
| 'bulkRequestedWagons'
|
||||
| 'wagonsRequired'
|
||||
| 'bookingContainers'
|
||||
| 'cargoType'
|
||||
@@ -9392,7 +9433,7 @@ export class TrainSchedulingService {
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon) — more wagons for the same cargo, so more tare to pull.
|
||||
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
|
||||
const tonsPerWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wagonTypeId, dims.capacityTons);
|
||||
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
|
||||
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
|
||||
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
|
||||
@@ -9877,6 +9918,10 @@ export class TrainSchedulingService {
|
||||
// Ordered corridor stops (route milestones; falls back to the two
|
||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||
stops: this.mapScheduleStops(schedule),
|
||||
// Per-yard loading/unloading time windows (start/end clicks) — the
|
||||
// detail page shows the origin's loading window; dispatch requires it
|
||||
// started when cargo boards there.
|
||||
stationWorkLogs: schedule.stationWorkLogs ?? {},
|
||||
// Gross ceiling the validator holds each leg to: the set's weakest
|
||||
// locomotive pull limit plus its overage tolerance. Booking weightTons
|
||||
// above are gross too, so the strip can sum them per leg against this.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
bulkTonWagonsForAllowedTypes,
|
||||
bulkTonWagonsRequired,
|
||||
bulkWagonsForAllowedTypes,
|
||||
@@ -177,6 +178,19 @@ describe('train-capacity.util', () => {
|
||||
expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3);
|
||||
});
|
||||
|
||||
it('NUMBER_OF_WAGONS: a requested count wins over the tonnage-derived one', () => {
|
||||
const req = { ...bulk(100), bulkRequestedWagons: 40 };
|
||||
// 100T on 70T wagons is 2 by tonnage — the customer asked for 40.
|
||||
expect(bulkTonWagonsRequired(req, null, 'nw5', 70)).toBe(40);
|
||||
expect(bulkWagonsForAllowedTypes(req, { wagonTypes: [{ id: 'nw5', capacityTons: 70 }] }, 70)).toBe(40);
|
||||
// Each wagon then carries the even share, not rated capacity.
|
||||
expect(bulkTonsPerWagonFor(req, null, 'nw5', 70)).toBe(2.5);
|
||||
// ceil(tons / evenShare) must land exactly on the requested count.
|
||||
const awkward = { ...bulk(100), bulkRequestedWagons: 3 };
|
||||
const share = bulkTonsPerWagonFor(awkward, null, 'nw5', 70);
|
||||
expect(Math.ceil(100 / share)).toBe(3);
|
||||
});
|
||||
|
||||
it('routes PER_ITEM and PER_TON through one call', () => {
|
||||
expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4);
|
||||
// PER_ITEM still wins where an item count is present.
|
||||
|
||||
@@ -114,6 +114,43 @@ export function bookingCargoTons(booking: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-requested wagon count of a NUMBER_OF_WAGONS bulk booking; 0 when
|
||||
* the booking carries none (every other cargo unit). The request was validated
|
||||
* against wagon capacity at booking creation, so sizing code honours it
|
||||
* verbatim instead of deriving a count from tonnage.
|
||||
*/
|
||||
export function requestedBulkWagons(booking: {
|
||||
bulkRequestedWagons?: number | string | null;
|
||||
}): number {
|
||||
const n = Math.floor(num(booking.bulkRequestedWagons));
|
||||
return n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-aware {@link bulkTonsPerWagon}: a NUMBER_OF_WAGONS booking fixed its
|
||||
* wagon count, so each wagon carries tons ÷ requested (the even spread the
|
||||
* customer asked for), never more. Rounded UP to 3 decimals so
|
||||
* ceil(tons / perWagon) lands exactly on the requested count instead of one
|
||||
* over on float error. Other bookings get the cargo-type figure unchanged.
|
||||
*/
|
||||
export function bulkTonsPerWagonFor(
|
||||
booking: Parameters<typeof bookingCargoTons>[0] & {
|
||||
bulkRequestedWagons?: number | string | null;
|
||||
},
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
capacityTons: number | string | null | undefined,
|
||||
): number {
|
||||
const base = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
|
||||
const requested = requestedBulkWagons(booking);
|
||||
if (!requested) return base;
|
||||
const tons = bookingCargoTons(booking);
|
||||
if (!(tons > 0)) return base;
|
||||
const evenShare = Math.ceil((tons / requested) * 1000) / 1000;
|
||||
return base > 0 ? Math.min(base, evenShare) : evenShare;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so
|
||||
* floor how many whole items fit one wagon, then ceil the wagon count:
|
||||
@@ -184,11 +221,16 @@ export function bulkTonsPerWagon(
|
||||
* usable per-wagon figure, so callers can fall back as before.
|
||||
*/
|
||||
export function bulkTonWagonsRequired(
|
||||
booking: Parameters<typeof bookingCargoTons>[0],
|
||||
booking: Parameters<typeof bookingCargoTons>[0] & {
|
||||
bulkRequestedWagons?: number | string | null;
|
||||
},
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
capacityTons: number | string | null | undefined,
|
||||
): number {
|
||||
// NUMBER_OF_WAGONS: the customer fixed the count — honour it verbatim.
|
||||
const requested = requestedBulkWagons(booking);
|
||||
if (requested) return requested;
|
||||
const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
|
||||
const tons = bookingCargoTons(booking);
|
||||
if (!(perWagon > 0) || !(tons > 0)) return 0;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
bulkTonWagonsRequired,
|
||||
consistViolations,
|
||||
} from '../train-capacity.util';
|
||||
@@ -193,8 +193,11 @@ export function buildBulkWagonPlan(
|
||||
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
|
||||
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
|
||||
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
|
||||
// A NUMBER_OF_WAGONS booking is "capped" at its even share (tons ÷ requested),
|
||||
// so it plans exactly the requested count.
|
||||
const cappedTonSlotsByBooking = bookings.map((b, i) =>
|
||||
itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity
|
||||
itemSlotsByBooking[i] > 0 ||
|
||||
bulkTonsPerWagonFor(b, b.cargoType, wagonType.id, capacity) >= capacity
|
||||
? 0
|
||||
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
|
||||
);
|
||||
@@ -330,6 +333,7 @@ function allocateBookingsToSlots(
|
||||
// bookings that column is an item COUNT, not tons.
|
||||
remainingWeightTons: roundTons(bookingCargoTons(booking)),
|
||||
cargoType: booking.cargoType,
|
||||
booking,
|
||||
}));
|
||||
|
||||
let bookingIndex = 0;
|
||||
@@ -343,10 +347,17 @@ function allocateBookingsToSlots(
|
||||
const booking = remaining[bookingIndex];
|
||||
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
|
||||
// as the wagon count — the plan reserved a wagon per capped chunk, so
|
||||
// pouring rated capacity into it would leave the last wagon empty.
|
||||
// pouring rated capacity into it would leave the last wagon empty. A
|
||||
// NUMBER_OF_WAGONS booking fills each wagon its even share (tons ÷
|
||||
// requested) for the same reason.
|
||||
const takeCap = Math.min(
|
||||
wagonRemaining,
|
||||
bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons),
|
||||
bulkTonsPerWagonFor(
|
||||
booking.booking,
|
||||
booking.cargoType,
|
||||
slot.wagonTypeId,
|
||||
slot.capacityTons,
|
||||
),
|
||||
);
|
||||
const allocatedWeightTons = roundTons(
|
||||
Math.min(takeCap, booking.remainingWeightTons),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
bulkWagonsForAllowedTypes,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
@@ -179,7 +180,7 @@ const shortageFor = (
|
||||
let seatable = 0;
|
||||
let usedWagons = 0;
|
||||
for (const { wt, free } of freeByType) {
|
||||
const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons));
|
||||
const perWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons));
|
||||
if (!(perWagon > 0) || free <= 0) continue;
|
||||
seatable += free * perWagon;
|
||||
usedWagons += free;
|
||||
@@ -188,7 +189,7 @@ const shortageFor = (
|
||||
const bestPerWagon = Math.max(
|
||||
1,
|
||||
...candidates.map((wt) =>
|
||||
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)),
|
||||
bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
@@ -583,7 +584,8 @@ export function planWagonsWithStock(params: {
|
||||
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
|
||||
const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId);
|
||||
if (!wagonType) continue;
|
||||
const room = bulkTonsPerWagon(
|
||||
const room = bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
open.slot.wagonTypeId,
|
||||
Number(open.slot.capacityTons),
|
||||
@@ -640,7 +642,20 @@ export function planWagonsWithStock(params: {
|
||||
openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
} else {
|
||||
take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
// NUMBER_OF_WAGONS: each wagon takes the even share (tons / requested),
|
||||
// not the full per-wagon cap — the loop then opens exactly that count.
|
||||
take = roundTons(
|
||||
Math.min(
|
||||
openedSlot.freeCapacityTons,
|
||||
bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
openedSlot.slot.wagonTypeId,
|
||||
openedSlot.slot.capacityTons,
|
||||
),
|
||||
remainingWeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
addAllocation(
|
||||
openedSlot.slot,
|
||||
|
||||
Reference in New Issue
Block a user