feat(train): enhance train history and scheduling features

- Added a reason field to train history entries for detach/maintenance actions.
- Updated TrainHistoryPanel to display the reason for wagon detachments.
- Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons.
- Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason.
- Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments.
- Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
This commit is contained in:
Marshal
2026-08-28 07:33:28 +00:00
parent 8b8870e85e
commit ba56974e32
26 changed files with 1435 additions and 583 deletions

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-wagon loading. Staff may confirm loading wagon-by-wagon instead of the
* whole booking at once:
* - bookings.loading_started_at — first wagon loaded; the booking stays PAID
* until every remaining wagon is LOADED (remaining = allocated cancelled).
* Also shields a mid-load booking from the dispatch "left behind" unassign.
* - wagon_booking_allocations.loaded_at / loaded_by_user_id — per-wagon
* confirmation audit.
* - booking_wagon_cancellations.fault — who caused an at-loading cancel of
* the never-loaded remainder: CUSTOMER (fee applies) or EDR (no fee, credit
* rebookable in full).
*/
export class PerWagonLoading3760000000000 implements MigrationInterface {
name = 'PerWagonLoading3760000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS loading_started_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS loaded_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS unloaded_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations
ADD COLUMN IF NOT EXISTS unloaded_by_user_id uuid`,
);
await queryRunner.query(
`ALTER TABLE freight.booking_wagon_cancellations
ADD COLUMN IF NOT EXISTS fault varchar(16)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_wagon_cancellations DROP COLUMN IF EXISTS fault`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_by_user_id`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_by_user_id`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_at`,
);
await queryRunner.query(
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_at`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS loading_started_at`,
);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Why a wagon left (or joined) the consist, on the adjustment log itself.
*
* SCHEDULED-run detach / send-to-maintenance now requires a reason instead of
* a second staffer's approval, so the reason has to read back where the change
* reads back: the train-builder History tab. Nullable — every other writer
* (trip cuts, couples, arrival returns) keeps logging without one.
*/
export class WagonAdjustmentReason3770000000000 implements MigrationInterface {
name = 'WagonAdjustmentReason3770000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
ADD COLUMN IF NOT EXISTS reason varchar(500)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
DROP COLUMN IF EXISTS reason`,
);
}
}

View File

@@ -156,3 +156,46 @@ describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
)).toBe(0);
});
});
/**
* Rebooking a NUMBER_OF_WAGONS bulk credit: the create path rejects the rebook
* unless the DTO carries a wagon count ("<cargo> is booked by wagons — enter
* the number of wagons needed"), and the quantities snapshot holds tons only.
* The count therefore has to come off the cancellation row itself.
*/
describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () => {
const svc = Object.create(BookingWagonCancellationService.prototype) as {
buildRebookDto(
row: unknown,
scheduledDate: string,
overrides?: unknown,
): { bulkLines?: { cargoWeightTons: number }[]; requestedWagons?: number };
};
it('carries the cancelled wagon count onto the rebook', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 2, weightTons: 140, cancelledQuantities: { bulkTons: 140 } },
'2026-09-10',
);
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 140 }]);
// Without this the create path throws before the booking is ever made.
expect(dto.requestedWagons).toBe(2);
});
it('rounds a fractional cut up to a whole wagon', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 0.5, weightTons: 35, cancelledQuantities: { bulkTons: 35 } },
'2026-09-10',
);
// Flooring would send 0 into a check that demands >= 1.
expect(dto.requestedWagons).toBe(1);
});
it('leaves the count off when nothing was cancelled', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 0, weightTons: 0, cancelledQuantities: { bulkTons: 12 } },
'2026-09-10',
);
expect(dto.requestedWagons).toBeUndefined();
});
});

View File

@@ -25,6 +25,8 @@ import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
@@ -35,6 +37,7 @@ import {
} from './booking-wagon-cancellations.repository';
import { BookingsRepository } from './bookings.repository';
import {
CancelRemainingWagonsDto,
RebookCancelledWagonsDto,
RebookContainerLineDto,
RequestWagonCancellationDto,
@@ -625,7 +628,14 @@ export class BookingWagonCancellationService {
this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`);
return;
}
if (row.status !== 'FEE_PENDING') return;
if (row.status !== 'FEE_PENDING') {
// At-loading cancels apply the cut immediately and leave the invoice
// open — settle only the payment stamp when the customer pays later.
if (!row.feePaidAt) {
await this.repo.update(row.id, { feePaidAt: new Date() });
}
return;
}
// The fee can settle after loading started (slow payment). Never cut
// loaded cargo: leave the row FEE_PENDING and alert staff to resolve
@@ -653,6 +663,25 @@ export class BookingWagonCancellationService {
return;
}
await this.applyCut(row, releasedEarly, { feeSettled: true });
this.logger.log(
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
);
}
/**
* Apply the cut to the booking: reduce quantities/wagons/amount, release the
* cancelled allocations, flip the row to CREDIT_AVAILABLE. Runs at fee
* settlement for the customer-requested flow (feeSettled: true) and
* immediately for at-loading cancels (feeSettled only when no fee is owed —
* EDR fault; a customer-fault cut leaves feePaidAt null until the open
* invoice settles via onFeePaid).
*/
private async applyCut(
row: BookingWagonCancellation,
releasedEarly: boolean,
opts: { feeSettled: boolean },
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const booking = await manager.getRepository(Booking).findOne({
where: { id: row.bookingId },
@@ -736,7 +765,7 @@ export class BookingWagonCancellationService {
await manager.getRepository(BookingWagonCancellation).update(row.id, {
status: 'CREDIT_AVAILABLE',
feePaidAt: new Date(),
...(opts.feeSettled ? { feePaidAt: new Date() } : {}),
weightTons: droppedWeight,
cancelledQuantities: quantities,
});
@@ -754,9 +783,145 @@ export class BookingWagonCancellationService {
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
);
}
this.logger.log(
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
}
/**
* Staff cancel of the never-loaded remainder mid-load: the operator loaded
* what physically rides and cuts the rest, so the booking shrinks to its
* loaded wagons, dispatch unblocks, and the warehouse only ever sees the
* final (smaller) booking. Unlike the customer flow the cut applies
* IMMEDIATELY — the train cannot wait for a fee payment:
* - CUSTOMER fault: cancellation fee invoiced, payable after; the credit
* row opens right away (feePaidAt stamps when the invoice settles).
* - EDR fault: no fee at all; the credit is rebookable in full.
*/
async cancelRemainingAtLoading(
bookingId: string,
dto: CancelRemainingWagonsDto,
userId?: string,
): Promise<BookingWagonCancellation> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`);
if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') {
throw new BadRequestException(
'Only a paid booking still loading can cancel its remaining wagons.',
);
}
if (booking.loadedAt) {
throw new BadRequestException(
'This booking is already fully loaded — there is nothing left to cancel.',
);
}
if (!booking.contractId) {
throw new BadRequestException(
'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).',
);
}
const open = await this.repo.findOpenForBooking(bookingId);
if (open) {
throw new ConflictException(
'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.',
);
}
const allocations = await this.dataSource
.getRepository(WagonBookingAllocation)
.createQueryBuilder('alloc')
.innerJoin(TrainSetWagon, 'slot', 'slot.id = alloc.train_set_wagon_id')
.innerJoin(
TrainSchedule,
'schedule',
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
{ scheduleId: dto.scheduleId },
)
.where('alloc.booking_id = :bookingId', { bookingId })
.getMany();
const loaded = allocations.filter(
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
);
const remaining = allocations.filter(
(a) => a.status !== 'LOADED' && a.status !== 'DEPARTED',
);
if (!loaded.length) {
throw new BadRequestException(
'Loading has not started for this booking — use the normal wagon cancellation flow.',
);
}
if (!remaining.length) {
throw new BadRequestException(
'Every wagon of this booking is loaded — there is nothing to cancel.',
);
}
const cut = await this.resolveRequestedCut(booking, {
wagonAllocationIds: remaining.map((r) => r.id),
} as RequestWagonCancellationDto);
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
const edrFault = !!dto.edrFault;
const fee = edrFault ? null : await this.priceFee(booking, cut);
const creditAmount = this.creditFor(booking, cut.wagons);
const row = await this.repo.create({
bookingId,
wagonsCancelled: cut.wagons,
weightTons: cut.weightTons,
cancelledQuantities: cut.quantities,
creditAmount,
feeRateId: fee?.rates[0]?.id ?? null,
feeAmount: fee?.amount ?? 0,
feeCurrency: fee?.currency ?? booking.paymentCurrency ?? 'ETB',
status: 'FEE_PENDING',
reason: dto.reason,
fault: edrFault ? 'EDR' : 'CUSTOMER',
requestedByUserId: userId ?? null,
});
let current = row;
if (fee && fee.amount > 0) {
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Booking,
sourceId: bookingId,
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: fee.currency,
lines: [
{
chargeType: 'CANCELLATION_FEE',
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference} cancelled at loading`,
quantity: cut.wagons,
unitRate: fee.perWagon,
amount: fee.amount,
currency: fee.currency,
metadata: { wagonCancellationId: row.id },
},
],
totalAmount: fee.amount,
status: Freight.InvoiceStatus.Issued,
});
current = (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row;
}
// The cut applies NOW — booking shrinks, allocations release, credit opens.
// EDR fault (or a zero fee) settles the fee side immediately; a customer-
// fault fee stays owed and stamps feePaidAt via onFeePaid when it settles.
await this.applyCut(current, false, { feeSettled: edrFault || !fee || fee.amount <= 0 });
// The booking now holds only loaded wagons — let the journey complete the
// load (PAID → IN_TRANSIT, warehouse inventory, milestones).
this.events.emit('booking.wagonsCancelledAtLoading', {
bookingId,
scheduleId: dto.scheduleId,
userId: userId ?? null,
});
this.notifyStaff(
booking,
'Wagons cancelled at loading',
`${booking.reference}: ${cut.wagons} unloaded wagon(s) cancelled (${edrFault ? 'EDR fault — no fee' : `customer fault — fee invoiced`}). Reason: ${dto.reason}`,
);
return this.mustFind(row.id);
}
/**
@@ -1725,6 +1890,14 @@ export class BookingWagonCancellationService {
}
dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }];
// NUMBER_OF_WAGONS cargo is booked by wagon count, not by tons: the create
// path rejects the rebook outright without it. The count is not in the
// quantities snapshot (which only carries tons) — it is the cancellation's
// own wagonsCancelled, so every existing credit rebooks without a backfill.
// Rounded UP: a fractional cut still needs a whole wagon to ride on, and
// flooring 0.5 would send 0 into a check that demands >= 1.
const cancelledWagons = Math.ceil(Number(row.wagonsCancelled ?? 0));
if (cancelledWagons >= 1) dto.requestedWagons = cancelledWagons;
return dto;
}

View File

@@ -100,6 +100,7 @@ import { BookingWagonCancellationService } from "./booking-wagon-cancellation.se
import {
FilterWagonCancellationsDto,
RebookCancelledWagonsDto,
CancelRemainingWagonsDto,
RequestWagonCancellationDto,
} from "./dto/wagon-cancellation.dto";
import {
@@ -640,6 +641,20 @@ export class BookingsController {
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
}
@Post(":id/wagon-cancellations/at-loading")
@BookingStaff(FREIGHT_PERMS.trainScheduling.load)
@ApiOperation({
summary:
"Staff: cancel the never-loaded remainder of a booking mid-load. The cut applies immediately (the train cannot wait); CUSTOMER fault invoices the fee to pay after, EDR fault charges nothing.",
})
async cancelRemainingWagonsAtLoading(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelRemainingWagonsDto,
@CurrentUser() user: TCurrentUser,
) {
return this.wagonCancellationService.cancelRemainingAtLoading(id, dto, user?.id);
}
@Get(":id/wagon-cancellations")
@ApiOperation({
summary: "Wagon-cancellation history of one booking (owner or staff)",

View File

@@ -3,9 +3,11 @@ import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
@@ -169,3 +171,28 @@ export class FilterWagonCancellationsDto {
@Min(1)
pageSize?: number;
}
/**
* Staff cancel of the never-loaded remainder of a booking mid-load: everything
* not yet LOADED on the schedule is cut, the booking shrinks to its loaded
* wagons, and the freed credit is rebookable. Fault decides the fee: CUSTOMER
* — cancellation fee invoiced (payable after the cut); EDR — no fee.
*/
export class CancelRemainingWagonsDto {
@ApiProperty({ description: 'Schedule the booking is being loaded on' })
@IsUUID('4')
scheduleId!: string;
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
@IsString()
@IsNotEmpty()
@MaxLength(2000)
reason!: string;
@ApiPropertyOptional({
description: 'The shortfall is EDR\'s fault (wagon shortage, yard problem) — no fee charged',
})
@IsOptional()
@IsBoolean()
edrFault?: boolean;
}

View File

@@ -132,6 +132,15 @@ export class BookingWagonCancellation extends BaseEntity {
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
/**
* At-loading cancels of the never-loaded remainder: who caused it.
* CUSTOMER — cancellation fee applies (invoice payable after the cut);
* EDR — no fee, the full credit is rebookable. Null for customer-requested
* cancellations (the pre-loading flow).
*/
@Column({ name: 'fault', type: 'varchar', length: 16, nullable: true })
fault?: 'CUSTOMER' | 'EDR' | null;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;

View File

@@ -588,6 +588,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
/**
* First wagon of this booking confirmed loaded (per-wagon loading). The
* booking stays PAID until every remaining wagon is LOADED — loadedAt then
* stamps the completion. Also shields the booking from the dispatch
* "left behind" unassign while mid-load.
*/
@Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true })
loadingStartedAt?: Date | null;
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
loadedByUserId?: string | null;

View File

@@ -39,6 +39,14 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId!: string | null;
/**
* Why the wagon left/joined the consist. Required by the builder for a
* detach or maintenance move on a SCHEDULED run (that reason replaced the
* old second-staff approval); null for every other adjustment.
*/
@Column({ name: 'reason', type: 'varchar', length: 500, nullable: true })
reason?: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
occurredAt!: Date;
}

View File

@@ -50,6 +50,20 @@ export class WagonBookingAllocation extends BaseEntity {
@Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true })
confirmedByUserId?: string | null;
/** Per-wagon loading confirmation (status LOADED) — when and by whom. */
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
loadedByUserId?: string | null;
/** Per-wagon unloading confirmation (status DEPARTED) — when and by whom. */
@Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true })
unloadedAt?: Date | null;
@Column({ name: 'unloaded_by_user_id', type: 'uuid', nullable: true })
unloadedByUserId?: string | null;
@OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation)
containerItems?: WagonAllocationContainerItem[];
}

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
Optional,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';
@@ -72,6 +72,117 @@ export class BookingJourneyService {
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
await this.assertBookingLoadable(schedule, booking);
return this.completeLoad(schedule, booking, userId ?? null);
}
/**
* Confirm ONE wagon of the booking loaded (per-wagon loading). The booking
* stays PAID while wagons remain; loading the last remaining wagon runs the
* whole-booking completion (IN_TRANSIT, warehouse inventory, GRN,
* milestones) exactly as the one-shot load does. Wagons that will NOT ride
* must be cancelled via the at-loading cancellation before the booking can
* complete (and before the train may dispatch).
*/
async loadWagon(
scheduleId: string,
bookingId: string,
allocationId: string,
userId?: string | null,
) {
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
await this.assertBookingLoadable(schedule, booking);
const allocations = await this.allocationsForBooking(
this.dataSource.manager,
scheduleId,
bookingId,
);
if (!allocations.length) {
throw new BadRequestException(
'This booking has no wagon allocations on the schedule — use the whole-booking load.',
);
}
const target = allocations.find((a) => a.id === allocationId);
if (!target) {
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
}
if (target.status === 'LOADED' || target.status === 'DEPARTED') {
throw new BadRequestException('This wagon is already loaded');
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WagonBookingAllocation).update(target.id, {
status: 'LOADED',
loadedAt: now,
loadedByUserId: userId ?? null,
});
if (!booking.loadingStartedAt) {
await manager
.getRepository(Booking)
.update(bookingId, { loadingStartedAt: now } as never);
}
// PARTIAL keeps the dispatch-readiness badge honest; completion below
// flips it to LOADED.
await manager
.getRepository(TrainScheduleBooking)
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'PARTIAL' });
});
const remaining = allocations.filter(
(a) => a.id !== target.id && a.status !== 'LOADED' && a.status !== 'DEPARTED',
).length;
if (remaining === 0) {
const done = await this.completeLoad(schedule, booking, userId ?? null);
return {
...done,
allocationId,
loadedWagons: allocations.length,
totalWagons: allocations.length,
completed: true,
};
}
return {
bookingId,
allocationId,
status: booking.status,
loadedWagons: allocations.length - remaining,
totalWagons: allocations.length,
completed: false,
};
}
/**
* The at-loading cancel shrank the booking to its loaded wagons — if every
* wagon left on it is LOADED, the load is complete: run the whole-booking
* completion. Fired by BookingWagonCancellationService.cancelRemainingAtLoading.
*/
@OnEvent('booking.wagonsCancelledAtLoading')
async onWagonsCancelledAtLoading(payload: {
bookingId: string;
scheduleId: string;
userId?: string | null;
}): Promise<void> {
try {
const allocations = await this.allocationsForBooking(
this.dataSource.manager,
payload.scheduleId,
payload.bookingId,
);
const loaded = allocations.filter(
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
).length;
if (!allocations.length || loaded < allocations.length) return;
await this.loadBooking(payload.scheduleId, payload.bookingId, payload.userId);
} catch (err) {
this.logger.error(
`Post-cancel load completion failed for booking ${payload.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** The pre-load gates shared by whole-booking and per-wagon loading. */
private async assertBookingLoadable(schedule: TrainSchedule, booking: Booking): Promise<void> {
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
throw new BadRequestException('Booking is already loaded');
}
@@ -86,6 +197,16 @@ export class BookingJourneyService {
// Export cargo must be in the warehouse with a GRN before it can be loaded,
// however it arrived and whatever it is allocated to.
await assertExportReceivedWithGrn(this.dataSource, booking);
}
/** The whole-booking load side effects — gates already passed. */
private async completeLoad(
schedule: TrainSchedule,
booking: Booking,
userId: string | null,
) {
const scheduleId = schedule.id;
const bookingId = booking.id;
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
// handover moment — the carriage acceptance sheet must go out to the
// customer right here, not on a receive event that will never fire.
@@ -165,6 +286,77 @@ export class BookingJourneyService {
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
await this.assertBookingUnloadable(schedule, booking);
return this.completeUnload(schedule, booking, userId ?? null);
}
/**
* Confirm ONE wagon of the booking unloaded (per-wagon unloading). Tracking
* only while wagons remain on the train — the booking stays IN_TRANSIT;
* unloading the last wagon runs the whole-booking completion (ARRIVED/
* COMPLETED, wagon settlement, events) exactly as the one-shot unload does.
*/
async unloadWagon(
scheduleId: string,
bookingId: string,
allocationId: string,
userId?: string | null,
) {
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
await this.assertBookingUnloadable(schedule, booking);
const allocations = await this.allocationsForBooking(
this.dataSource.manager,
scheduleId,
bookingId,
);
if (!allocations.length) {
throw new BadRequestException(
'This booking has no wagon allocations on the schedule — use the whole-booking unload.',
);
}
const target = allocations.find((a) => a.id === allocationId);
if (!target) {
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
}
if (target.status === 'DEPARTED') {
throw new BadRequestException('This wagon is already unloaded');
}
const now = new Date();
await this.dataSource.getRepository(WagonBookingAllocation).update(target.id, {
status: 'DEPARTED',
unloadedAt: now,
unloadedByUserId: userId ?? null,
});
const remaining = allocations.filter(
(a) => a.id !== target.id && a.status !== 'DEPARTED',
).length;
if (remaining === 0) {
const done = await this.completeUnload(schedule, booking, userId ?? null);
return {
...done,
allocationId,
unloadedWagons: allocations.length,
totalWagons: allocations.length,
completed: true,
};
}
return {
bookingId,
allocationId,
status: booking.status,
unloadedWagons: allocations.length - remaining,
totalWagons: allocations.length,
completed: false,
};
}
/** The pre-unload gates shared by whole-booking and per-wagon unloading. */
private async assertBookingUnloadable(
schedule: TrainSchedule,
booking: Booking,
): Promise<void> {
if (booking.status !== 'IN_TRANSIT') {
throw new BadRequestException(
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
@@ -173,7 +365,16 @@ export class BookingJourneyService {
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
}
/** The whole-booking unload side effects — gates already passed. */
private async completeUnload(
schedule: TrainSchedule,
booking: Booking,
userId: string | null,
) {
const scheduleId = schedule.id;
const bookingId = booking.id;
// Intercity has no clearance/delivery tail — unloading completes it. Import/
// export continue into clearance, keyed on the booking's own arrival.
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
@@ -498,7 +699,17 @@ export class BookingJourneyService {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not assigned to this schedule');
// The schedule↔booking LINK is the same authority the workspace list
// (listYardWork) renders from — some flows (export train pick) create it
// with wagon allocations before bookings.train_schedule_id is stamped.
// Trusting only the column made those rows show a Load button that
// always 400'd.
const linked = await this.dataSource.getRepository(TrainScheduleBooking).findOne({
where: { trainScheduleId: scheduleId, bookingId },
});
if (!linked) {
throw new BadRequestException('Booking is not assigned to this schedule');
}
}
return { schedule, booking };
}

View File

@@ -707,6 +707,46 @@ export class TrainSchedulingController {
return this.bookingJourneyService.unloadBooking(id, bookingId);
}
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/load")
@TrainSchedulingLoad()
@ApiOperation({
summary:
"Confirm ONE wagon of the booking loaded (per-wagon loading). The booking stays PAID until every remaining wagon is LOADED; the last wagon runs the whole-booking load completion.",
})
loadScheduleBookingWagon(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@Param("allocationId", ParseUUIDPipe) allocationId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingJourneyService.loadWagon(
id,
bookingId,
allocationId,
resolveAuthUserId(user),
);
}
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/unload")
@TrainSchedulingUnload()
@ApiOperation({
summary:
"Confirm ONE wagon of the booking unloaded (per-wagon unloading). The booking stays IN_TRANSIT until the last wagon, which runs the whole-booking unload completion.",
})
unloadScheduleBookingWagon(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@Param("allocationId", ParseUUIDPipe) allocationId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingJourneyService.unloadWagon(
id,
bookingId,
allocationId,
resolveAuthUserId(user),
);
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingLoad()
@ApiOperation({

View File

@@ -2946,6 +2946,10 @@ export class TrainSchedulingService {
// The yard plan this departure was SOLD against must match where the steel
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
await this.assertPlannedYardsAligned(schedule);
// Per-wagon loading: a booking mid-load is neither ridable nor removable —
// every wagon must be LOADED, or the never-loaded remainder cancelled
// (at-loading cancellation), before the train departs.
await this.assertNoPartiallyLoadedBookings(schedule);
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -3144,6 +3148,42 @@ export class TrainSchedulingService {
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
* (their charge sits on the credit ledger) yet ride from accept.
*/
/**
* Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
* SOME still PLANNED/RESERVED must resolve before departure — load the rest
* or cancel it (which shrinks the booking to its loaded wagons). Blocking
* here beats silently unassigning: unassign would delete LOADED allocations
* and strand cargo that is physically on the train.
*/
private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise<void> {
if (!schedule.trainSetId) return;
const rows: Array<{ reference: string; loaded: string; total: string }> =
await this.dataSource.query(
`SELECT b.reference,
COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded,
COUNT(*) AS total
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
JOIN freight.bookings b ON b.id = a.booking_id
WHERE tsw.train_set_id = $1
AND a.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND b.deleted_at IS NULL
GROUP BY b.id, b.reference
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
[schedule.trainSetId],
);
if (rows.length) {
const detail = rows
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
.join(', ');
throw new BadRequestException(
`Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
);
}
}
private async unloadedOriginBoarderIds(
scheduleId: string,
originYardId: string,
@@ -3157,6 +3197,7 @@ export class TrainSchedulingService {
AND b.deleted_at IS NULL
AND b.origin_yard_id = $2
AND b.loaded_at IS NULL
AND b.loading_started_at IS NULL
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
AND b.is_government = false
AND (b.status = 'PAID'

View File

@@ -1,15 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class SendWagonToMaintenanceDto {
@ApiPropertyOptional({
@ApiProperty({
description:
"Why the wagon is going to maintenance. Stored on the wagon's status-history " +
'log alongside the train it was detached from, matching the fleet desk flow.',
"Why the wagon is going to maintenance — required. Stored on the wagon's " +
'status-history log alongside the train it was detached from, and on the ' +
"train's wagon-adjustment history.",
maxLength: 500,
})
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(500)
note?: string;
note!: string;
}

View File

@@ -1,18 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
export class CreateWagonDetachRequestDto {
/**
* Detach a wagon from the consist. The reason is always required — it is
* recorded both as an auto-approved wagon_detach_requests audit row and on the
* train's wagon-adjustment history (the History tab).
*/
export class DetachWagonDto {
@ApiProperty({
enum: WagonDetachRequestAction,
description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.',
})
@IsEnum(WagonDetachRequestAction)
action!: WagonDetachRequestAction;
@ApiProperty({
description: 'Why the wagon must leave the scheduled consist. Shown to the approver.',
description: 'Why the wagon leaves the consist — required.',
maxLength: 500,
})
@IsString()
@@ -20,14 +16,3 @@ export class CreateWagonDetachRequestDto {
@MaxLength(500)
reason!: string;
}
export class DecideWagonDetachRequestDto {
@ApiPropertyOptional({
description: 'Decision note — required when rejecting, optional when approving.',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -25,10 +25,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
import {
CreateWagonDetachRequestDto,
DecideWagonDetachRequestDto,
} from './dto/wagon-detach-request.dto';
import { DetachWagonDto } from './dto/wagon-detach-request.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
@@ -51,7 +48,6 @@ import { TrainBuilderService } from './train-builder.service';
FREIGHT_PERMS.trains.changeWagonYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
FREIGHT_PERMS.trains.approveWagonDetach,
])
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}
@@ -185,29 +181,38 @@ export class TrainBuilderController {
@Delete(':id/wagons/:wagonId')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Detach one wagon from the consist' })
@ApiOperation({ summary: 'Detach one wagon from the consist — a reason is required' })
removeWagon(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: DetachWagonDto,
) {
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
return this.trainBuilderService.removeWagon(
id,
wagonId,
resolveAuthUserId(user),
dto.reason,
);
}
@Post(':id/wagons/:wagonId/maintenance')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
@ApiOperation({
summary:
'Detach one wagon and move it to MAINTENANCE status — a reason (note) is required',
})
sendWagonToMaintenance(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto?: SendWagonToMaintenanceDto,
@Body() dto: SendWagonToMaintenanceDto,
) {
return this.trainBuilderService.sendWagonToMaintenance(
id,
wagonId,
resolveAuthUserId(user),
dto?.note,
dto.note,
);
}
@@ -220,65 +225,6 @@ export class TrainBuilderController {
return this.trainBuilderService.listDetachRequests(id);
}
@Post(':id/wagons/:wagonId/detach-requests')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({
summary:
'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run',
})
createDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@Body() dto: CreateWagonDetachRequestDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.createDetachRequest(
id,
wagonId,
dto,
resolveAuthUserId(user),
);
}
@Post(':id/detach-requests/:requestId/approve')
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
@ApiOperation({
summary:
'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester',
})
approveDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('requestId', ParseUUIDPipe) requestId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto?: DecideWagonDetachRequestDto,
) {
return this.trainBuilderService.decideDetachRequest(
id,
requestId,
'APPROVE',
resolveAuthUserId(user),
dto?.note,
);
}
@Post(':id/detach-requests/:requestId/reject')
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
@ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' })
rejectDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('requestId', ParseUUIDPipe) requestId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: DecideWagonDetachRequestDto,
) {
return this.trainBuilderService.decideDetachRequest(
id,
requestId,
'REJECT',
resolveAuthUserId(user),
dto.note,
);
}
@Post(':id/reorder-wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })

View File

@@ -0,0 +1,95 @@
import { BadRequestException } from '@nestjs/common';
import { TrainBuilderService } from './train-builder.service';
import { WagonDetachRequestAction } from './entities/wagon-detach-request.entity';
/**
* Every detach / send-to-maintenance carries a reason — scheduled run or not
* (the reason replaced the old second-staff approval). The recorder rejects a
* missing/blank one before anything is written, and stores a trimmed, capped
* copy on the audit row otherwise.
*/
describe('TrainBuilderService — detach reason is always required', () => {
const svc = Object.create(TrainBuilderService.prototype) as {
recordDetachReason(
manager: unknown,
trainId: string,
wagonId: string,
action: WagonDetachRequestAction,
reason: string | null | undefined,
userId?: string | null,
): Promise<void>;
};
/** Minimal EntityManager: records what the recorder would persist. */
const managerSpy = () => {
const saved: Array<Record<string, unknown>> = [];
return {
saved,
getRepository: (entity: { name: string }) =>
entity.name === 'Wagon'
? { findOne: async () => ({ wagonNumber: 'NW5-0412' }) }
: {
create: (row: Record<string, unknown>) => row,
save: async (row: Record<string, unknown>) => {
saved.push(row);
return row;
},
},
};
};
it.each([undefined, null, '', ' '])('refuses a blank reason (%p)', async (reason) => {
const manager = managerSpy();
await expect(
svc.recordDetachReason(
manager,
'train-1',
'wagon-1',
WagonDetachRequestAction.Detach,
reason,
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
// Nothing is written when the reason is missing.
expect(manager.saved).toHaveLength(0);
});
it('records the reason as an auto-approved audit row', async () => {
const manager = managerSpy();
await svc.recordDetachReason(
manager,
'train-1',
'wagon-1',
WagonDetachRequestAction.Maintenance,
' Brake shoe worn through ',
'user-1',
);
expect(manager.saved).toHaveLength(1);
const row = manager.saved[0];
expect(row).toMatchObject({
trainId: 'train-1',
wagonId: 'wagon-1',
wagonNumber: 'NW5-0412',
action: WagonDetachRequestAction.Maintenance,
reason: 'Brake shoe worn through',
// No second person: the actor is both requester and decider.
status: 'APPROVED',
requestedBy: 'user-1',
decidedBy: 'user-1',
});
});
it('caps an over-long reason at the column width', async () => {
const manager = managerSpy();
await svc.recordDetachReason(
manager,
'train-1',
'wagon-1',
WagonDetachRequestAction.Detach,
'x'.repeat(900),
null,
);
expect(String(manager.saved[0].reason)).toHaveLength(500);
});
});

View File

@@ -30,7 +30,6 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import {
@@ -253,6 +252,7 @@ export class TrainBuilderService {
yardLabel: string | null;
actor: string | null;
scheduleReference: string | null;
reason: string | null;
occurredAt: Date;
}>,
] = await Promise.all([
@@ -270,6 +270,7 @@ export class TrainBuilderService {
COALESCE(y.label, y.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
ts.reference AS "scheduleReference",
l.reason,
l.occurred_at AS "occurredAt"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
@@ -491,8 +492,9 @@ export class TrainBuilderService {
: null,
},
activeSchedules: schedules,
// Composition is frozen while the train is out on a dispatched run.
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
// The built train is always editable — dispatched/arrived runs render from
// their frozen snapshot, so consist edits reach only DRAFT/SCHEDULED runs.
editable: true,
};
}
@@ -754,22 +756,40 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string, userId?: string | null) {
/**
* Detach one wagon and close the sequence gap it leaves. On a SCHEDULED run
* the detach still executes directly, but a reason is required and recorded
* in wagon_detach_requests (auto-approved) — the audit trail without the
* former second-staff approval step.
*/
async removeWagon(
id: string,
wagonId: string,
userId?: string | null,
reason?: string | null,
) {
const pending = await this.dataSource.transaction(async (manager) => {
await this.assertDetachNeedsNoApproval(manager, id);
return this.removeWagonCore(manager, id, wagonId, userId);
await this.recordDetachReason(
manager,
id,
wagonId,
WagonDetachRequestAction.Detach,
reason,
userId,
);
return this.removeWagonCore(manager, id, wagonId, userId, reason);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
/** Transactional body of removeWagon — also runs under an approved detach request. */
/** Transactional body of removeWagon — `reason` rides into the history log. */
private async removeWagonCore(
manager: EntityManager,
id: string,
wagonId: string,
userId?: string | null,
reason?: string | null,
): Promise<PendingWindowCheck | null> {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
@@ -791,6 +811,7 @@ export class TrainBuilderService {
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
wagon.currentYardId ?? train.currentYardId ?? null,
reason,
);
}
@@ -805,8 +826,16 @@ export class TrainBuilderService {
userId?: string | null,
note?: string | null,
) {
// `note` is the required reason — recordDetachReason rejects it empty.
const pending = await this.dataSource.transaction(async (manager) => {
await this.assertDetachNeedsNoApproval(manager, id);
await this.recordDetachReason(
manager,
id,
wagonId,
WagonDetachRequestAction.Maintenance,
note,
userId,
);
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
});
await this.reconcileWindowAfterConsistChange(pending);
@@ -883,96 +912,54 @@ export class TrainBuilderService {
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
yardId,
note,
);
}
}
/**
* Direct-detach guard: while this train carries a live SCHEDULED run,
* removing a wagon changes a departure customers already booked against, so
* it is a two-person action — refuse here and point at the request flow.
* DRAFT stays freely editable; DISPATCHED is already frozen by
* getEditableTrain (the train is IN_SERVICE).
* Every detach / send-to-maintenance carries a REASON — scheduled run or
* not — and an auto-approved wagon_detach_requests row records who did it
* and why (the audit trail that replaced the former second-staff approval).
* DISPATCHED trains never reach here: getEditableTrain freezes them.
*/
private async assertDetachNeedsNoApproval(
private async recordDetachReason(
manager: EntityManager,
trainId: string,
): Promise<void> {
const scheduled = await this.findScheduledRun(manager, trainId);
if (scheduled) {
throw new ConflictException(
`Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`,
);
}
}
private async findScheduledRun(
manager: EntityManager,
trainId: string,
): Promise<{ id: string; reference: string | null } | null> {
const rows: { id: string; reference: string | null }[] = await manager.query(
`SELECT ts.id, ts.reference
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.status = 'SCHEDULED'
AND ts.deleted_at IS NULL
LIMIT 1`,
[trainId],
);
return rows[0] ?? null;
}
/**
* File a detach/maintenance approval request for a wagon on a SCHEDULED
* train. The request carries the reason; a different staffer with
* trains:approve_wagon_detach decides it (approval executes the detach).
*/
async createDetachRequest(
id: string,
wagonId: string,
dto: CreateWagonDetachRequestDto,
action: WagonDetachRequestAction,
reason: string | null | undefined,
userId?: string | null,
) {
return this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
const scheduled = await this.findScheduledRun(manager, train.id);
if (!scheduled) {
throw new ConflictException(
'This train has no SCHEDULED run — detach the wagon directly, no approval needed',
);
}
// Refuse up front what an approval could never execute (booked
// allocations pin the wagon) — but release nothing yet: slots are only
// touched when the approved detach actually runs.
await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true });
const repo = manager.getRepository(WagonDetachRequest);
const open = await repo.findOne({
where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending },
});
if (open) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} already has a pending detach request`,
);
}
return repo.save(
repo.create({
trainId: train.id,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
action: dto.action,
reason: dto.reason.trim(),
requestedBy: userId ?? null,
}),
): Promise<void> {
const trimmed = reason?.trim();
if (!trimmed) {
throw new BadRequestException(
`Give a reason for ${
action === WagonDetachRequestAction.Maintenance
? 'sending this wagon to maintenance'
: 'detaching this wagon'
}`,
);
});
}
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
const repo = manager.getRepository(WagonDetachRequest);
const now = new Date();
await repo.save(
repo.create({
trainId,
wagonId,
wagonNumber: wagon?.wagonNumber ?? wagonId,
action,
reason: trimmed.slice(0, 500),
status: WagonDetachRequestStatus.Approved,
requestedBy: userId ?? null,
decidedBy: userId ?? null,
decidedAt: now,
}),
);
}
/** All detach/maintenance requests of this train, newest first — the approval audit trail. */
/** All detach/maintenance records of this train, newest first — the audit trail. */
async listDetachRequests(trainId: string) {
const rows: Array<{
id: string;
@@ -1012,68 +999,6 @@ export class TrainBuilderService {
return rows;
}
/**
* Decide a pending request. Approve executes the detach (or maintenance
* move) in the same transaction that stamps the decision, so an approved row
* can never exist without its detach having happened. The requester cannot
* approve their own request; a rejection must carry a note.
*/
async decideDetachRequest(
id: string,
requestId: string,
decision: 'APPROVE' | 'REJECT',
userId?: string | null,
note?: string | null,
) {
const pending = await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(WagonDetachRequest);
const request = await repo.findOne({
where: { id: requestId, trainId: id },
lock: { mode: 'pessimistic_write' },
});
if (!request) {
throw new NotFoundException(`Detach request ${requestId} not found on this train`);
}
if (request.status !== WagonDetachRequestStatus.Pending) {
throw new ConflictException(
`This request was already ${request.status.toLowerCase()}`,
);
}
const decisionNote = note?.trim() || null;
if (decision === 'REJECT') {
if (!decisionNote) {
throw new BadRequestException('A note explaining the rejection is required');
}
await repo.update(request.id, {
status: WagonDetachRequestStatus.Rejected,
decidedBy: userId ?? null,
decidedAt: new Date(),
decisionNote,
});
return null;
}
// The 4-eyes point of the gate: requester and approver are different people.
if (request.requestedBy && userId && request.requestedBy === userId) {
throw new ConflictException(
'You filed this request — a different staff member must approve it',
);
}
const pendingCheck =
request.action === WagonDetachRequestAction.Maintenance
? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason)
: await this.removeWagonCore(manager, id, request.wagonId, userId);
await repo.update(request.id, {
status: WagonDetachRequestStatus.Approved,
decidedBy: userId ?? null,
decidedAt: new Date(),
decisionNote,
});
return pendingCheck;
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
/**
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
@@ -1113,6 +1038,9 @@ export class TrainBuilderService {
wagon: Wagon,
opts: { checkOnly?: boolean } = {},
): Promise<void> {
// Only DRAFT/SCHEDULED runs still follow the live consist, so only they can
// pin a wagon. A DISPATCHED/ARRIVED run reads its frozen snapshot and is
// unaffected by what happens to the physical train behind it.
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
await manager.query(
`SELECT tsw.id, tsw.train_set_id, ts.status,
@@ -1123,15 +1051,18 @@ export class TrainBuilderService {
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = $1
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL`,
[wagon.id],
);
if (!rows.length) return;
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
// Cargo already allocated to a live run keeps its wagon: the booking must be
// unassigned from the slot first, so a customer's shipment can never lose its
// wagon as a side effect of editing the train.
if (rows.some((r) => Number(r.allocs) > 0)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
`Wagon ${wagon.wagonNumber} is carrying cargo on a live schedule — unassign its bookings before removing it`,
);
}
if (opts.checkOnly) return;
@@ -1162,25 +1093,9 @@ export class TrainBuilderService {
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
}
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
// is allowed — the pinned schedules' consists are resequenced below so
// they can never desync from the built train's real order.
const dispatched: { exists: boolean }[] = await manager.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
AND ts.status = 'DISPATCHED'
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[[...current]],
);
if (dispatched.length > 0) {
throw new ConflictException(
'This train is dispatched — wagons cannot be reordered while it is rolling.',
);
}
// Reorder is allowed at any time, dispatched runs included: a DISPATCHED
// schedule renders the order frozen in its snapshot, and only the
// DRAFT/SCHEDULED consists resequenced below follow the built train.
for (let i = 0; i < dto.wagonIds.length; i++) {
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
}
@@ -1402,6 +1317,7 @@ export class TrainBuilderService {
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
userId: string | null,
yardId: string | null,
reason?: string | null,
): Promise<PendingWindowCheck | null> {
if (!changes.length) return null;
const trainSet = await manager
@@ -1443,6 +1359,7 @@ export class TrainBuilderService {
wagonNumber: c.wagonNumber,
adjustedByUserId: userId,
yardId,
reason: reason?.trim() || null,
occurredAt: now,
}),
),
@@ -1481,17 +1398,21 @@ export class TrainBuilderService {
}
/** Load + freeze the train row for edit; block edits while it is out on a run. */
/**
* The built train is editable at ANY time, including while it is out on a
* dispatched run. A dispatched/arrived schedule froze its own wagon plan into
* `wagonAllocationSnapshot` at the transition and renders from that, so it can
* never be disturbed by later consist edits; only DRAFT/SCHEDULED runs follow
* the live train (see syncLiveScheduleAfterConsistChange). Per-wagon safety
* still applies — assertDetachableAndReleaseStaleSlots refuses to pull a wagon
* whose cargo is allocated to a live run.
*/
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
const train = await manager.getRepository(Train).findOne({
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.InService) {
throw new ConflictException(
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
);
}
return train;
}