add goverment booking

This commit is contained in:
Marshal
2026-07-31 00:41:51 +00:00
parent 34e4a7d13f
commit 65f18b4796
35 changed files with 913 additions and 88 deletions

View File

@@ -112,15 +112,9 @@ export class BookingContractService {
const templateKey = this.templateResolver.resolve(booking);
const summary = this.buildContractSummary(booking);
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
// from becoming ready — the document is (re)rendered lazily on view/download.
try {
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
// No eager PDF render here: streamContract re-renders the document on every
// view/download, so rendering now only adds a Chromium launch (seconds, or a
// 60s asset-load hang) inside the staff-accept request.
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
@@ -132,6 +126,22 @@ export class BookingContractService {
return updated!;
}
/**
* Government bookings skip the whole customer contract flow (approve →
* CONTRACT_READY → sign chain): their contract is stamped server-side at
* creation/expedite WITHOUT touching booking status — the booking is already
* PAID/allocatable and the contract can be signed at any time. Idempotent.
*/
async generateContractForGovernment(bookingId: string): Promise<void> {
const booking = await this.requireBooking(bookingId);
if (!booking.isGovernment || booking.contractGeneratedAt) return;
await this.bookingsRepository.update(bookingId, {
contractSummary: this.buildContractSummary(booking),
contractTemplateKey: this.templateResolver.resolve(booking),
contractGeneratedAt: new Date(),
} as never);
}
async streamContract(bookingId: string) {
const booking = await this.requireBooking(bookingId);
const templateKey =
@@ -152,8 +162,13 @@ export class BookingContractService {
const booking = await this.requireBooking(bookingId);
const role = dto.role as ContractSignerRole;
// Government contracts are order-free and status-free: either party may
// sign at any time (each once) — the booking is already expedited past the
// customer contract flow, so no status gate applies.
if (role === 'CUSTOMER') {
assertBookingStatus(booking, ['CONTRACT_READY']);
if (!booking.isGovernment) {
assertBookingStatus(booking, ['CONTRACT_READY']);
}
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'CUSTOMER',
@@ -162,7 +177,9 @@ export class BookingContractService {
throw new BadRequestException('Customer has already signed this contract');
}
} else {
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
if (!booking.isGovernment) {
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
}
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'STAFF',
@@ -235,20 +252,30 @@ export class BookingContractService {
);
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
// Government bookings keep their operational status (PAID) — a signature
// must never pull them back into the customer workflow.
if (!booking.isGovernment) updates.status = 'SIGNED_CUSTOMER';
} else {
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
if (!booking.isGovernment) {
updates.lockedAt = now;
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
}
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
// clearance bookings enter operations after the GL document gate.
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
// clearance bookings enter operations after the GL document gate. Government
// bookings are already in the pool from expedite — signing changes nothing.
if (
role === 'STAFF' &&
!booking.isGovernment &&
!clearanceCode &&
updated?.trainScheduleId
) {
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
}
try {

View File

@@ -32,6 +32,8 @@ import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { BookingContractService } from './booking-contract.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
@@ -103,6 +105,10 @@ export class BookingsService {
private readonly vehiclesService: VehiclesService,
private readonly pdfRender: PdfRenderService,
private readonly events: EventEmitter2,
@Inject(forwardRef(() => BookingContractService))
private readonly bookingContractService: BookingContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) {}
async assignCustomerTruck(
@@ -982,6 +988,21 @@ export class BookingsService {
warnings.push(...consolidation.messages);
}
// Government bookings pass every customer step at creation: the server
// expedites them to PAID/Eligible, generates the contract (signable at any
// time) and queues priority placement. Best-effort — the booking row is
// already inserted, so a late failure must not 500 the whole create; the
// idempotent expedite endpoint remains the retry path.
if (isGovernment) {
try {
full = await this.governmentExpedite(booking.id, userId ?? 'system');
} catch (err) {
warnings.push(
`Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`,
);
}
}
return { booking: full, warnings };
}
@@ -1830,13 +1851,22 @@ export class BookingsService {
return false;
}
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
/**
* Expedite a government booking past every customer step: PAID + Eligible
* (no commercial hold, no payment), contract generated server-side (signable
* at any time), and the (route, day) fill kicked immediately so it grabs a
* seat on any open train — government-first, preempting commercial cargo if
* the day is full. Runs automatically at creation; the endpoint remains as a
* no-op-safe retry for older bookings.
*/
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
// Idempotent: create() already expedites — a repeat call changes nothing.
if (booking.status === 'PAID') return booking;
const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}
@@ -1848,12 +1878,22 @@ export class BookingsService {
holdStartedAt: null,
holdExpiresAt: null,
});
await this.bookingContractService.generateContractForGovernment(id);
await this.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
// Priority placement: run the day-level fill now instead of waiting for a
// batch tick — the pool sorts government first and preempts if needed.
if (booking.scheduledDate) {
this.bookingBatchService.enqueueRouteDayProcessing(
booking.originYardId,
booking.destinationYardId,
eatDay(booking.scheduledDate),
);
}
return this.findById(id);
}

View File

@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
@ApiPropertyOptional()
stampImageUrl?: string | null;
}
export class ContractViewDto {

View File

@@ -139,6 +139,17 @@ export class SchedulingRescheduleService {
actorUserId?: string,
) {
const plan = await this.previewReschedule(scheduleId, dto);
// Gov bookings may never be pushed off a train. Checked here (not only in
// unassignBooking) because the displacement loop below swallows unassign
// errors and force-detaches the booking anyway.
const govDisplaced = plan.displaced.filter((b) => b.isGovernment);
if (govDisplaced.length) {
throw new BadRequestException(
`Government bookings cannot be removed from a train: ${govDisplaced
.map((b) => b.reference)
.join(', ')}`,
);
}
const expectedDisplaced = new Set(plan.displaced.map((b) => b.id));
const providedDisplaced = new Set(dto.displacedBookingIds);
if (

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength } from 'class-validator';
export class SaveSignatureDto {
@ApiProperty()
@@ -13,6 +13,15 @@ export class SaveSignatureDto {
@IsString()
@MinLength(20)
signatureImageBase64!: string;
@ApiPropertyOptional({
description:
'Company stamp/seal image as base64 (with or without data URL prefix). Omit to keep the existing saved stamp.',
})
@IsOptional()
@IsString()
@MinLength(20)
stampImageBase64?: string;
}
export class SavedSignatureDto {
@@ -21,4 +30,7 @@ export class SavedSignatureDto {
@ApiProperty({ nullable: true })
signatureImageUrl!: string | null;
@ApiProperty({ nullable: true })
stampImageUrl!: string | null;
}

View File

@@ -22,4 +22,11 @@ export class SavedSignature extends BaseEntity {
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
@Column({ name: 'stamp_file_id', type: 'uuid', nullable: true })
stampFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'stamp_file_id' })
stampFile?: FileRecord | null;
}

View File

@@ -33,6 +33,7 @@ export class SignaturesController {
userId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
stampImageBase64: dto.stampImageBase64,
});
return this.signaturesService.getForUser(userId);
}

View File

@@ -16,7 +16,7 @@ export class SignaturesRepository extends BaseRepository<SavedSignature> {
findByUserId(userId: string): Promise<SavedSignature | null> {
return this.repository.findOne({
where: { userId } as never,
relations: ['signatureFile'],
relations: ['signatureFile', 'stampFile'],
});
}

View File

@@ -13,6 +13,8 @@ export interface UpsertSignatureInput {
userId: string;
signerDisplayName: string;
signatureImageBase64: string;
/** Optional company stamp/seal; omitted = keep the existing saved stamp. */
stampImageBase64?: string;
}
@Injectable()
@@ -31,15 +33,65 @@ export class SignaturesService {
return {
signerDisplayName: saved.signerDisplayName,
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
stampImageUrl: await this.inlineImageUrl(saved.stampFile?.url),
};
}
/** Insert or update the user's reusable signature, storing the image in MinIO. */
/** Insert or update the user's reusable signature (and optional stamp), storing the images in MinIO. */
async upsertForUser(input: UpsertSignatureInput): Promise<SavedSignature> {
const buffer = this.decodeSignatureImage(input.signatureImageBase64);
const file: Express.Multer.File = {
fieldname: 'signature',
originalname: `signature-${input.userId}.png`,
// Capture the previously referenced files so we can remove them only AFTER
// the saved_signatures row is repointed — deleting first would violate the
// FK constraint (saved_signatures.*_file_id -> files.id).
const existing = await this.signaturesRepository.findByUserId(input.userId);
const previousFileId = existing?.signatureFileId ?? null;
const previousStampFileId = existing?.stampFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
});
const stampRecord = input.stampImageBase64
? await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'stamp',
file: this.toUploadFile('stamp', input.userId, input.stampImageBase64),
})
: null;
const saved = await this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
// Omitted stamp keeps whatever was saved before.
...(stampRecord ? { stampFileId: stampRecord.id } : {}),
});
const staleIds = [
previousFileId !== fileRecord.id ? previousFileId : null,
stampRecord && previousStampFileId !== stampRecord.id
? previousStampFileId
: null,
].filter((id): id is string => Boolean(id));
if (staleIds.length) {
await this.dataSource.getRepository(FileRecord).delete(staleIds);
}
return saved;
}
private toUploadFile(
kind: 'signature' | 'stamp',
userId: string,
base64: string,
): Express.Multer.File {
const buffer = this.decodeSignatureImage(base64);
return {
fieldname: kind,
originalname: `${kind}-${userId}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
@@ -49,33 +101,6 @@ export class SignaturesService {
filename: '',
path: '',
};
// Capture the previously referenced file so we can remove it only AFTER the
// saved_signatures row is repointed — deleting it first would violate the
// FK constraint (saved_signatures.signature_file_id -> files.id).
const existing = await this.signaturesRepository.findByUserId(input.userId);
const previousFileId = existing?.signatureFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file,
});
const saved = await this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource
.getRepository(FileRecord)
.delete({ id: previousFileId });
}
return saved;
}
private async inlineImageUrl(

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

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