mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
add goverment booking
This commit is contained in:
@@ -1475,7 +1475,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
trainSet: { locomotive: true, train: true },
|
||||
// locomotives (plural) too — the caps SUM the whole set's pull; the
|
||||
// single legacy column alone under-reports a two-loco train by half.
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
// Yards supply the route's display name for `routeName` below;
|
||||
@@ -1538,7 +1540,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
});
|
||||
|
||||
board.push(this.buildScheduleSummary(s, items));
|
||||
board.push(
|
||||
this.buildScheduleSummary(
|
||||
s,
|
||||
items,
|
||||
new Map(
|
||||
bookings.map((b) => [
|
||||
b.id,
|
||||
{
|
||||
originYardId: b.originYardId ?? null,
|
||||
destinationYardId: b.destinationYardId ?? null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
@@ -1853,47 +1869,63 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: null;
|
||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
// Per-leg committed weight: a booking holds weight only on the edges it
|
||||
// rides, so the meter compares the HEAVIEST single edge against the pull
|
||||
// limit. Whole-route bookings (or yards missing from the stop list) load
|
||||
// every edge — never under-reported.
|
||||
// Per-leg committed usage: a booking holds capacity only on the edges it
|
||||
// rides, so every meter compares the HEAVIEST single edge against its cap
|
||||
// — weight, wagons and length alike. Whole-route bookings (or yards
|
||||
// missing from the stop list) load every edge — never under-reported.
|
||||
const stops = legCtx?.stops ?? [];
|
||||
let usedWeightTons = round2(
|
||||
committed.reduce((sum, i) => sum + i.weightTons, 0),
|
||||
);
|
||||
let allocatedWagons = allocated.reduce((sum, i) => sum + i.wagons, 0);
|
||||
let allocatedLengthMeters = round2(
|
||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
|
||||
);
|
||||
let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null;
|
||||
if (legCtx && stops.length > 2) {
|
||||
const stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
|
||||
const edges = new Array<number>(stops.length - 1).fill(0);
|
||||
for (const item of committed) {
|
||||
const yards = legCtx.yardsByBookingId.get(item.id);
|
||||
const edgeCount = stops.length - 1;
|
||||
const legOf = (bookingId: string): { from: number; to: number } => {
|
||||
const yards = legCtx.yardsByBookingId.get(bookingId);
|
||||
const from = yards?.originYardId
|
||||
? stopIndex.get(yards.originYardId)
|
||||
: undefined;
|
||||
const to = yards?.destinationYardId
|
||||
? stopIndex.get(yards.destinationYardId)
|
||||
: undefined;
|
||||
const leg =
|
||||
from != null && to != null && from < to
|
||||
? { from, to }
|
||||
: { from: 0, to: edges.length };
|
||||
for (let e = leg.from; e < leg.to; e += 1) edges[e] += item.weightTons;
|
||||
return from != null && to != null && from < to
|
||||
? { from, to }
|
||||
: { from: 0, to: edgeCount };
|
||||
};
|
||||
const weightEdges = new Array<number>(edgeCount).fill(0);
|
||||
for (const item of committed) {
|
||||
const leg = legOf(item.id);
|
||||
for (let e = leg.from; e < leg.to; e += 1) weightEdges[e] += item.weightTons;
|
||||
}
|
||||
const wagonEdges = new Array<number>(edgeCount).fill(0);
|
||||
const lengthEdges = new Array<number>(edgeCount).fill(0);
|
||||
for (const item of allocated) {
|
||||
const leg = legOf(item.id);
|
||||
for (let e = leg.from; e < leg.to; e += 1) {
|
||||
wagonEdges[e] += item.wagons;
|
||||
lengthEdges[e] += item.lengthMeters;
|
||||
}
|
||||
}
|
||||
const label = (yardId: string) =>
|
||||
legCtx.labelByYardId.get(yardId) ?? yardId;
|
||||
legUsage = edges.map((weight, i) => ({
|
||||
legUsage = weightEdges.map((weight, i) => ({
|
||||
from: label(stops[i]),
|
||||
to: label(stops[i + 1]),
|
||||
usedWeightTons: round2(weight),
|
||||
}));
|
||||
usedWeightTons = round2(Math.max(0, ...edges));
|
||||
usedWeightTons = round2(Math.max(0, ...weightEdges));
|
||||
allocatedWagons = Math.max(0, ...wagonEdges);
|
||||
allocatedLengthMeters = round2(Math.max(0, ...lengthEdges));
|
||||
}
|
||||
|
||||
return {
|
||||
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
||||
allocatedLengthMeters: round2(
|
||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
|
||||
),
|
||||
allocatedWagons,
|
||||
allocatedLengthMeters,
|
||||
maxLengthMeters: caps ? caps.maxLengthMeters : null,
|
||||
usedWeightTons,
|
||||
maxWeightTons: caps ? caps.maxWeightTons : null,
|
||||
@@ -1919,11 +1951,46 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return new Map(yards.map((y) => [y.id, y.label ?? y.code]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Corridor stops + labels from the already-loaded route graph (milestones
|
||||
* with yards) — the list flow must not fire a query per schedule row.
|
||||
*/
|
||||
private stopsFromGraph(s: TrainSchedule): {
|
||||
stops: string[];
|
||||
labelByYardId: Map<string, string>;
|
||||
} {
|
||||
const milestones = [...(s.route?.milestones ?? [])].sort(
|
||||
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||
);
|
||||
const stops: string[] = [];
|
||||
const labelByYardId = new Map<string, string>();
|
||||
const push = (yardId?: string | null, label?: string | null) => {
|
||||
if (!yardId || labelByYardId.has(yardId)) return;
|
||||
stops.push(yardId);
|
||||
labelByYardId.set(yardId, label ?? yardId);
|
||||
};
|
||||
if (milestones.length >= 2) {
|
||||
for (const m of milestones) push(m.yardId, m.yard?.label ?? m.yard?.code);
|
||||
} else {
|
||||
push(s.originStationId, s.originStation?.label ?? s.originStation?.code);
|
||||
push(
|
||||
s.destinationStationId,
|
||||
s.destinationStation?.label ?? s.destinationStation?.code,
|
||||
);
|
||||
}
|
||||
return { stops, labelByYardId };
|
||||
}
|
||||
|
||||
private buildScheduleSummary(
|
||||
s: TrainSchedule,
|
||||
items: BatchBoardBooking[],
|
||||
yardsByBookingId: Map<
|
||||
string,
|
||||
{ originYardId: string | null; destinationYardId: string | null }
|
||||
>,
|
||||
): BatchBoardSchedule {
|
||||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||
const { stops, labelByYardId } = this.stopsFromGraph(s);
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
@@ -1966,9 +2033,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, {
|
||||
stops: [],
|
||||
labelByYardId: new Map(),
|
||||
yardsByBookingId: new Map(),
|
||||
stops,
|
||||
labelByYardId,
|
||||
yardsByBookingId,
|
||||
trainLengthMeters: this.builtTrainLengthOf(s),
|
||||
}),
|
||||
counts: {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class SwitchGovernmentBookingDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Government booking to allocate onto the train' })
|
||||
@IsUUID()
|
||||
governmentBookingId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
isArray: true,
|
||||
description: 'Assigned commercial bookings to switch out in its place',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
removeBookingIds!: string[];
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
||||
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
||||
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
||||
import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto";
|
||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||
@@ -408,6 +409,25 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/switch-government-booking")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Switch out commercial bookings to allocate a government booking in their place",
|
||||
})
|
||||
switchGovernmentBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SwitchGovernmentBookingDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.switchGovernmentBooking(
|
||||
id,
|
||||
dto.governmentBookingId,
|
||||
dto.removeBookingIds,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/composition-removals")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Get removal log for a schedule" })
|
||||
|
||||
@@ -1351,4 +1351,88 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toThrow(/over its/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('government booking protection', () => {
|
||||
const scheduleId = 'sched-gov-1';
|
||||
const govBooking = makeBooking('gov-1', 'BKG-GOV', 200, 10, '20FT', 10, undefined, undefined, undefined, {
|
||||
isGovernment: true,
|
||||
wagonsRequired: 10,
|
||||
});
|
||||
const commercial = makeBooking('bk-1', 'BKG-COM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
|
||||
wagonsRequired: 5,
|
||||
});
|
||||
|
||||
const scheduleGraph = {
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
trainSetId: 'ts-1',
|
||||
trainSet: {
|
||||
id: 'ts-1',
|
||||
locomotive,
|
||||
wagons: [{ id: 'tsw-1' }, { id: 'tsw-2' }],
|
||||
},
|
||||
scheduleBookings: [{ bookingId: 'gov-1' }, { bookingId: 'bk-1' }],
|
||||
};
|
||||
|
||||
it('unassignBooking rejects a government booking', async () => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
|
||||
bookingsRepository.findById = jest.fn().mockResolvedValue(govBooking);
|
||||
|
||||
await expect(service.unassignBooking(scheduleId, 'gov-1')).rejects.toThrow(
|
||||
/Government bookings cannot be removed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('switchGovernmentBooking rejects a non-government incoming booking', async () => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValueOnce([commercial]);
|
||||
|
||||
await expect(
|
||||
service.switchGovernmentBooking(scheduleId, 'bk-1', ['gov-1']),
|
||||
).rejects.toThrow(/Only government bookings/);
|
||||
});
|
||||
|
||||
it('switchGovernmentBooking rejects when the freed wagons are fewer than the government booking needs', async () => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === WagonBookingAllocation) {
|
||||
return { find: jest.fn().mockResolvedValue([{ bookingId: 'bk-1' }]) };
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
});
|
||||
bookingsRepository.findByIdsForScheduling.mockImplementation((ids: string[]) =>
|
||||
Promise.resolve(
|
||||
ids.map((id) => (id === 'gov-1' ? govBooking : commercial)),
|
||||
),
|
||||
);
|
||||
jest
|
||||
.spyOn(service as never as { resolveTrainLimitConfig: () => unknown }, 'resolveTrainLimitConfig')
|
||||
.mockResolvedValue({} as never);
|
||||
// Gov booking fits the plan (10 slots) but the switched-out booking only
|
||||
// frees 5 wagons — the user-facing wagon rule must still reject it.
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as { validateBookingsForScheduling: () => unknown },
|
||||
'validateBookingsForScheduling',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
valid: true,
|
||||
violations: [],
|
||||
warnings: [],
|
||||
deferredBookings: [],
|
||||
bookings: [govBooking],
|
||||
wagonPlan: Array.from({ length: 10 }, (_, i) => ({
|
||||
sequenceNo: i + 1,
|
||||
allocations: [{ bookingId: 'gov-1' }],
|
||||
})),
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
service.switchGovernmentBooking(scheduleId, 'gov-1', ['bk-1']),
|
||||
).rejects.toThrow(/free only 5/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1886,6 +1886,11 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (booking?.isGovernment) {
|
||||
throw new BadRequestException(
|
||||
'Government bookings cannot be removed from a train. They can only be switched onto another allocation.',
|
||||
);
|
||||
}
|
||||
const bookingReference = booking?.reference ?? null;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -5384,6 +5389,7 @@ export class TrainSchedulingService {
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||||
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
|
||||
status: booking.status,
|
||||
isGovernment: Boolean(booking.isGovernment),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7189,6 +7195,7 @@ export class TrainSchedulingService {
|
||||
// dispatch. Defaults UNLOADED for links written before the column.
|
||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||
isGovernment: Boolean(sb.booking?.isGovernment),
|
||||
})) ?? [],
|
||||
// Ordered corridor stops (route milestones; falls back to the two
|
||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||
@@ -7361,6 +7368,142 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Government-priority switch: free wagons by unassigning the selected
|
||||
* commercial bookings, then allocate the government booking in their place.
|
||||
* The gov booking must need no more wagons than the switched-out bookings
|
||||
* free (ops selects more bookings otherwise), and the post-switch
|
||||
* composition is fully validated BEFORE anything is unassigned so a failing
|
||||
* switch never leaves the train half-emptied.
|
||||
*/
|
||||
async switchGovernmentBooking(
|
||||
scheduleId: string,
|
||||
governmentBookingId: string,
|
||||
removeBookingIds: string[],
|
||||
userId?: string,
|
||||
) {
|
||||
if (removeBookingIds.includes(governmentBookingId)) {
|
||||
throw new BadRequestException('Government booking cannot be switched out by itself');
|
||||
}
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot switch bookings on a schedule in status ${schedule.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [govBooking] = await this.bookingsRepository.findByIdsForScheduling([
|
||||
governmentBookingId,
|
||||
]);
|
||||
if (!govBooking) {
|
||||
throw new NotFoundException(`Booking ${governmentBookingId} not found`);
|
||||
}
|
||||
if (!govBooking.isGovernment) {
|
||||
throw new BadRequestException('Only government bookings can be switched onto a train');
|
||||
}
|
||||
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
if (wagonAssignedIds.has(governmentBookingId)) {
|
||||
throw new BadRequestException('Government booking is already allocated on this train');
|
||||
}
|
||||
|
||||
const removed = await this.bookingsRepository.findByIdsForScheduling(removeBookingIds);
|
||||
if (removed.length !== removeBookingIds.length) {
|
||||
throw new NotFoundException('One or more bookings to switch out were not found');
|
||||
}
|
||||
const notOnTrain = removed.filter((b) => !wagonAssignedIds.has(b.id));
|
||||
if (notOnTrain.length) {
|
||||
throw new BadRequestException(
|
||||
`Not allocated on this train: ${notOnTrain.map((b) => b.reference).join(', ')}`,
|
||||
);
|
||||
}
|
||||
const govRemoved = removed.filter((b) => b.isGovernment);
|
||||
if (govRemoved.length) {
|
||||
throw new BadRequestException(
|
||||
`Government bookings cannot be switched out: ${govRemoved.map((b) => b.reference).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Dry-run the post-switch composition: survivors + the gov booking.
|
||||
const survivorIds = [...wagonAssignedIds].filter((id) => !removeBookingIds.includes(id));
|
||||
const previewDto = {
|
||||
bookingIds: [...survivorIds, governmentBookingId],
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
false,
|
||||
limits,
|
||||
scheduleId,
|
||||
);
|
||||
const freedWagons = removed.reduce((sum, b) => sum + Number(b.wagonsRequired ?? 0), 0);
|
||||
if (!validation.valid) {
|
||||
throw new BadRequestException({
|
||||
message: `Switch validation failed: ${validation.violations.join('; ')}`,
|
||||
violations: validation.violations,
|
||||
warnings: validation.warnings,
|
||||
});
|
||||
}
|
||||
if (!validation.bookings.some((b) => b.id === governmentBookingId)) {
|
||||
throw new BadRequestException(
|
||||
`Switching out ${removed.map((b) => b.reference).join(', ')} frees ${freedWagons} wagon(s) — not enough for this government booking. Select more bookings to switch out.`,
|
||||
);
|
||||
}
|
||||
const govWagons = sumWagonsRequired(govBooking, validation.wagonPlan);
|
||||
if (govWagons > freedWagons) {
|
||||
throw new BadRequestException(
|
||||
`Government booking needs ${govWagons} wagon(s) but the selected bookings free only ${freedWagons}. Select more bookings to switch out.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Same container-number gate as single-booking assignment, applied to the
|
||||
// incoming gov booking only.
|
||||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
const missingForGov = findMissingContainerNumberIssues(units, placements).find(
|
||||
(m) => m.bookingId === governmentBookingId,
|
||||
);
|
||||
if (missingForGov) {
|
||||
throw new BadRequestException({
|
||||
message: missingForGov.issue,
|
||||
violations: [missingForGov.issue],
|
||||
});
|
||||
}
|
||||
|
||||
// ponytail: unassign + assign run as sequential own-transaction steps, not
|
||||
// one atomic unit — the dry-run above means the assign step can only fail
|
||||
// on a concurrent edit; staff re-add from the eligible pool if it does.
|
||||
for (const booking of removed) {
|
||||
await this.unassignBooking(scheduleId, booking.id, userId);
|
||||
}
|
||||
|
||||
const assignableSet = new Set(validation.bookings.map((b) => b.id));
|
||||
const assignPlacements = placementsForBookings(placements, assignableSet, units);
|
||||
return this.assignBookingsToSchedule(
|
||||
scheduleId,
|
||||
{
|
||||
bookingIds: validation.bookings.map((b) => b.id),
|
||||
containerPlacements: containerBookings.length > 0 ? assignPlacements : undefined,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||||
async previewAllocationForSchedule(
|
||||
scheduleId: string,
|
||||
|
||||
Reference in New Issue
Block a user