diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 8b8b92f09..07f4d25d3 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -157,10 +157,14 @@ export class ContractViewModelBuilder { pricing, rateSchedule, signatures, - canSignCustomer: - booking.status === 'CONTRACT_READY' && !hasCustomer, - canSignStaff: - booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, + // Government contracts are generated at creation and signable at any + // time, in any order — no status gate, no customer-first sequencing. + canSignCustomer: booking.isGovernment + ? !hasCustomer + : booking.status === 'CONTRACT_READY' && !hasCustomer, + canSignStaff: booking.isGovernment + ? !hasStaff + : booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, diff --git a/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts b/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts new file mode 100644 index 000000000..3beafb6bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSavedSignatureStamp3090000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.saved_signatures + ADD COLUMN IF NOT EXISTS stamp_file_id UUID NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.saved_signatures + DROP COLUMN IF EXISTS stamp_file_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 5297f3d57..99f053545 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -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 { + 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 { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 2c5e6ed88..d54bd1190 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { 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); } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts index 919652af6..0dceadf43 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts @@ -20,6 +20,9 @@ export class SavedSignatureViewDto { @ApiPropertyOptional() signatureImageUrl?: string | null; + + @ApiPropertyOptional() + stampImageUrl?: string | null; } export class ContractViewDto { diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index e84eb6124..fefbbc868 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -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 ( diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts index a5ae63100..a1f245e23 100644 --- a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts +++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts index 08263cf7d..3814eb017 100644 --- a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts +++ b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts index d112edef3..24dd0aa5c 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts @@ -33,6 +33,7 @@ export class SignaturesController { userId, signerDisplayName: dto.signerDisplayName, signatureImageBase64: dto.signatureImageBase64, + stampImageBase64: dto.stampImageBase64, }); return this.signaturesService.getForUser(userId); } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts index 70c04ad7b..de8007d65 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts @@ -16,7 +16,7 @@ export class SignaturesRepository extends BaseRepository { findByUserId(userId: string): Promise { return this.repository.findOne({ where: { userId } as never, - relations: ['signatureFile'], + relations: ['signatureFile', 'stampFile'], }); } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 7137ab6a5..836888dab 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -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 { - 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( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 92f96dfa5..06e8a5f1c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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(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(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(edgeCount).fill(0); + const lengthEdges = new Array(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; + } { + const milestones = [...(s.route?.milestones ?? [])].sort( + (a, b) => a.sequenceNo - b.sequenceNo, + ); + const stops: string[] = []; + const labelByYardId = new Map(); + 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: { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts new file mode 100644 index 000000000..c72a1879b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index b7e9a750f..4f0c8903a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 0d724a040..80030c6c3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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/); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index dc7283c37..e4af8e6b5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index 5f4f4d7a7..ca895b70b 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -5,6 +5,7 @@ import toast from "react-hot-toast"; import { api } from "@/services/api"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { Card, CardContent, @@ -40,6 +41,7 @@ export function MySignatureCard() { const [open, setOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + const [stampData, setStampData] = useState(null); const defaultName = user?.name?.en || user?.username || user?.email || ""; @@ -47,6 +49,7 @@ export function MySignatureCard() { const openDialog = () => { setSignerName(saved?.signerDisplayName ?? defaultName); setSignatureData(null); + setStampData(saved?.stampImageUrl ?? null); setOpen(true); }; @@ -56,6 +59,10 @@ export function MySignatureCard() { { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, + // Only send the stamp when it changed — omitted keeps the saved one. + ...(stampData && stampData !== saved?.stampImageUrl + ? { stampImageBase64: stampData } + : {}), }, { onSuccess: () => { @@ -101,6 +108,18 @@ export function MySignatureCard() { You have not saved a signature yet.

)} + {saved?.stampImageUrl && ( +
+
+ My saved company stamp +
+

Company stamp

+
+ )} @@ -126,6 +145,11 @@ export function MySignatureCard() { /> + + ) : null} {booking.customer} @@ -102,6 +124,7 @@ export function EligibleBookingsPanel({ onSelectionChange, assignedIds = [], freightType, + onSwitch, }: { items: EligibleContainerBooking[]; isLoading?: boolean; @@ -109,6 +132,7 @@ export function EligibleBookingsPanel({ onSelectionChange: (ids: string[]) => void; assignedIds?: string[]; freightType?: FreightType; + onSwitch?: (booking: EligibleContainerBooking) => void; }) { const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]); @@ -230,6 +254,7 @@ export function EligibleBookingsPanel({ freightType={freightType} selected={selectedIds.includes(booking.id)} onToggle={() => toggle(booking.id)} + onSwitch={onSwitch} /> ))} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBookingsStep.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBookingsStep.tsx index 9241a03ea..6765d7360 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBookingsStep.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBookingsStep.tsx @@ -7,7 +7,7 @@ import { Tabs, Text, } from "@mantine/core"; -import { ArrowRight, Package, Train } from "lucide-react"; +import { ArrowRight, Landmark, Package, Train } from "lucide-react"; import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling"; @@ -17,6 +17,7 @@ export type AssignedBookingRow = { id: string; reference: string; weightTons?: number; + isGovernment?: boolean; }; export function ScheduleBookingsStep({ @@ -29,6 +30,7 @@ export function ScheduleBookingsStep({ freightType, canRemove, onRemove, + onSwitch, }: { assignedBookings: AssignedBookingRow[]; eligibleItems: EligibleContainerBooking[]; @@ -39,6 +41,7 @@ export function ScheduleBookingsStep({ freightType?: FreightType; canRemove?: boolean; onRemove?: (bookingId: string) => void; + onSwitch?: (booking: EligibleContainerBooking) => void; }) { return ( @@ -91,6 +94,16 @@ export function ScheduleBookingsStep({ {booking.weightTons}T ) : null} + {booking.isGovernment ? ( + } + > + Government + + ) : null} @@ -130,6 +143,7 @@ export function ScheduleBookingsStep({ onSelectionChange={onSelectionChange} assignedIds={assignedIds} freightType={freightType} + onSwitch={onSwitch} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/SwitchGovernmentBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/SwitchGovernmentBookingModal.tsx new file mode 100644 index 000000000..04efe6532 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/SwitchGovernmentBookingModal.tsx @@ -0,0 +1,143 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Modal, + Stack, + Text, +} from "@mantine/core"; +import { Landmark } from "lucide-react"; + +import type { + EligibleContainerBooking, + TrainScheduleDetail, +} from "@/types/trainScheduling"; + +/** + * Government-priority switch confirmation: pick the assigned commercial + * bookings to take off the train so the government booking can have their + * wagons. Mount with key={govBooking.id} so selection resets per booking. + */ +export function SwitchGovernmentBookingModal({ + opened, + onClose, + govBooking, + assignedBookings, + loading, + onConfirm, +}: { + opened: boolean; + onClose: () => void; + govBooking: EligibleContainerBooking | null; + assignedBookings: TrainScheduleDetail["bookings"]; + loading?: boolean; + onConfirm: (removeBookingIds: string[]) => void; +}) { + const [selected, setSelected] = useState([]); + + const candidates = useMemo( + () => assignedBookings.filter((b) => !b.isGovernment && b.wagonAssigned), + [assignedBookings], + ); + const freedWagons = candidates + .filter((b) => selected.includes(b.id)) + .reduce((sum, b) => sum + (b.wagonsRequired ?? 0), 0); + + const toggle = (id: string) => + setSelected((ids) => + ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id], + ); + + return ( + + + Switch in government booking + + } + radius="lg" + size="lg" + > + + + Government bookings have priority. Select the booking(s) to switch out — + together they must free at least as many wagons as{" "} + + {govBooking?.reference} + {" "} + needs. Switched-out customers are notified to rebook. + + + {candidates.length ? ( + + {candidates.map((b) => ( + + toggle(b.id)} + label={ + + + {b.reference} + + + {b.customer} + + + } + /> + + + {b.weightTons}T + + + {b.wagonsRequired ?? "?"} wagon{b.wagonsRequired === 1 ? "" : "s"} + + + + ))} + + ) : ( + + No commercial bookings with wagons on this train to switch out. + + )} + + + + Frees {freedWagons} wagon{freedWagons === 1 ? "" : "s"} + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index f77191551..31b74488c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -366,6 +366,8 @@ export const URL_CONSTANTS = { }, UNASSIGN_BOOKING: (scheduleId: string, bookingId: string) => `/train-scheduling/schedules/${scheduleId}/bookings/${bookingId}`, + SWITCH_GOVERNMENT_BOOKING: (id: string) => + `/train-scheduling/schedules/${id}/switch-government-booking`, PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 657bcc0ae..66a8a5b48 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -1,6 +1,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArrowLeft, + FileSignature, FolderOpen, Layers, LayoutGrid, @@ -254,6 +255,20 @@ export default function BookingRequestDetailPage() { booking={booking} mutations={mutations} /> + {booking.isGovernment && booking.contractSummary && ( + + )} {booking.customsClearingEnabled && ( @@ -116,6 +135,11 @@ export function MySignatureCard() { /> +