Merge pull request #1434 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-28 10:34:31 +03:00
committed by GitHub
32 changed files with 1762 additions and 646 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

@@ -1,6 +1,10 @@
import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
bulkTonWagonsRequired,
bulkTonsPerWagonFor,
} from '../train-scheduling/train-capacity.util';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
@@ -107,3 +111,91 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
).rejects.toThrow(/already shares a wagon/i);
});
});
/**
* A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and
* bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it
* alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking
* re-inflates to its pre-cancel count on the next allocation and each wagon
* carries tons / stale-count instead of the real even share.
*/
describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
// 980T over 14 wagons (70T each), 2 wagons cancelled.
const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 };
const droppedWeight = 140;
const wagonsCancelled = 2;
// The decrement applied in applyPaidCut's booking update.
const after = {
...before,
cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight,
bulkRequestedWagons: Math.max(
0,
Math.floor(before.bulkRequestedWagons - wagonsCancelled),
),
};
it('reallocates at the reduced count, not the pre-cancel one', () => {
expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14);
expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12);
});
it('keeps tons-per-wagon at the real even share', () => {
// Stale count would spread 840T over 14 wagons → 60T each.
expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70);
});
it('cancelling every wagon leaves no requested count behind', () => {
const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14));
expect(all).toBe(0);
expect(bulkTonWagonsRequired(
{ ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all },
undefined,
'nw5',
70,
)).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 },
@@ -704,8 +733,21 @@ export class BookingWagonCancellationService {
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
const isFull = wagonsLeft <= 0;
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
// booking to its pre-cancel count on the next allocation (and shrinks
// tons-per-wagon to tons / stale-count), so shrink it with the cut.
const requestedWagonsLeft = booking.bulkRequestedWagons
? Math.max(
0,
Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)),
)
: null;
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: Math.max(0, wagonsLeft),
...(requestedWagonsLeft !== null
? { bulkRequestedWagons: requestedWagonsLeft }
: {}),
cargoTotalWeightVgm: Math.max(
0,
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
@@ -723,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,
});
@@ -741,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);
}
/**
@@ -1712,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

@@ -201,6 +201,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
// Wagon types carry the rated capacity the booking forms need to reject
// a wagon count whose even share overloads a wagon (see maxTonsPerWagon).
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoTypeWagonTypes')
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
.leftJoinAndSelect('contract.signatures', 'signatures')
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')

View File

@@ -13,7 +13,9 @@ import { YardCountry } from '@edr/types';
//
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
@@ -858,6 +860,29 @@ export class ContractsService {
);
}
// NUMBER_OF_WAGONS booking forms need the heaviest load one wagon may take
// so they can reject a wagon count whose even share overloads a wagon —
// the client-side twin of ContractBookingService.assertWagonShareFits.
// The raw wagonTypes join rows are dropped: only the derived cap ships.
for (const scope of contract.cargoScope ?? []) {
const cargoType = scope.cargoType as
| (CargoType & { maxTonsPerWagon?: number | null })
| null
| undefined;
if (!cargoType) continue;
const allowed = (cargoType.wagonTypes ?? []).filter(
(wt) => Number(wt.capacityTons) > 0,
);
cargoType.maxTonsPerWagon = allowed.length
? Math.max(
...allowed.map((wt) =>
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
),
)
: null;
delete cargoType.wagonTypes;
}
// Surface the staff "request changes" note so the portal can show the
// customer what to fix. Degrade to null on lookup failure — a missing note
// must never 500 a contract fetch.

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;
}

View File

@@ -43,6 +43,7 @@ import {
Flame,
Link2,
MapPin,
MoveRight,
Package,
Receipt,
Repeat,
@@ -204,6 +205,19 @@ function emptyLine(size: string): ContainerLineDraft {
};
}
/**
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
* by the API from the cargo type's allowed wagon types. Null/undefined when no
* wagon type is configured — the wagon-count check then falls away.
*/
function bulkMaxTonsPerWagon(
contract: Freight.IContract,
): number | null | undefined {
return contract.cargoScope?.find(
(scope) => scope.cargoType?.maxTonsPerWagon != null,
)?.cargoType?.maxTonsPerWagon;
}
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
@@ -1000,8 +1014,20 @@ export default function GlCreateBookingForm() {
}
if (bulkUom === "NUMBER_OF_WAGONS") {
const wagons = Number(bulk.requestedWagons || 0);
const maxPerWagon = Number(
(contract && bulkMaxTonsPerWagon(contract)) || 0,
);
if (!Number.isInteger(wagons) || wagons < 1) {
errs.wagons = "Enter the number of wagons needed (at least 1).";
} else if (qty > 0 && maxPerWagon > 0 && qty / wagons > maxPerWagon) {
// Too few wagons for the tonnage can never ride: 200T across 3 wagons
// is 66.67T each on a 50T wagon. Mirrors the server's
// assertWagonShareFits so the button blocks before the API 400s.
errs.wagons =
`${qty} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
`${Math.round((qty / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
`this cargo carries at most ${maxPerWagon}T — request at least ` +
`${Math.ceil(qty / maxPerWagon)} wagons.`;
}
}
const h = Number(bulk.hazardousQuantity || 0);
@@ -1017,7 +1043,7 @@ export default function GlCreateBookingForm() {
errs.reefer = `Can't exceed the cargo quantity (${qty}).`;
}
return errs;
}, [isContainer, bulk, bulkUom]);
}, [isContainer, bulk, bulkUom, contract]);
const dateError =
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined;
@@ -1479,12 +1505,12 @@ export default function GlCreateBookingForm() {
const header = (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
<Title order={1} fw={800} fz={28} style={{ letterSpacing: "-0.01em" }}>
{completeBookingId ? "Complete shipment booking" : "New Shipment Booking"}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
? `Clearance is finalized. Enter cargo details and the binding shipment day to complete this booking under contract ${contract.reference}.`
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
</Text>
</Box>
@@ -1603,20 +1629,49 @@ export default function GlCreateBookingForm() {
styles={fieldStyles}
/>
) : (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Text fz={14} fw={600}>
{selectedRoute?.originYard?.label ??
selectedRoute?.originYard?.code ??
"—"}{" "}
{" "}
{selectedRoute?.destinationYard?.label ??
selectedRoute?.destinationYard?.code ??
"—"}
</Text>
<Text fz={12} c="dimmed" mt={2}>
<Group
wrap="nowrap"
gap={16}
align="center"
px={18}
py={18}
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
background: "#FBFCFD",
}}
>
<Box>
<Text fz={15} fw={700} c="#10202F">
{selectedRoute?.originYard?.label ??
selectedRoute?.originYard?.code ??
"—"}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
Origin yard
</Text>
</Box>
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
<Box>
<Text fz={15} fw={700} c="#10202F">
{selectedRoute?.destinationYard?.label ??
selectedRoute?.destinationYard?.code ??
"—"}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
Destination yard
</Text>
</Box>
<Box style={{ flex: 1 }} />
<Badge
variant="light"
color="teal"
radius={8}
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
>
{contract.tradeDirection}
</Text>
</Paper>
</Badge>
</Group>
)}
</StepCard>
@@ -2322,16 +2377,6 @@ export default function GlCreateBookingForm() {
even numbers. Add one more 20ft container or remove one — book{" "}
{ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}.
</Alert>
) : showErrors && !formValid ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : partnerError ? (
// The review button is disabled while the parent booking is
// incomplete, so the click that would reveal the errors never
@@ -2346,7 +2391,35 @@ export default function GlCreateBookingForm() {
{partnerError}
</Alert>
) : null}
<Group justify="flex-end">
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{showErrors && !formValid && !oddBlocksSubmit && (
<>
<AlertCircle
size={15}
color="#C0392B"
style={{ flexShrink: 0 }}
/>
<Text fz={13} fw={500} c="#C0392B">
Fix the highlighted fields to review the price.
</Text>
</>
)}
</Group>
<Group gap="sm" wrap="nowrap">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(
completeBookingId
? `/dashboard/clearance/${completeBookingId}`
: "/dashboard/contracts/clearance",
)
}
>
Cancel
</Button>
<Tooltip
label={
oddBlocksSubmit
@@ -2376,6 +2449,7 @@ export default function GlCreateBookingForm() {
</Button>
</Box>
</Tooltip>
</Group>
</Group>
</Box>
</Box>

View File

@@ -1,6 +1,15 @@
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
import {
ArrowLeftRight,
History,
MapPin,
MessageSquare,
Minus,
Plus,
TrainFront,
User,
} from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
@@ -49,7 +58,8 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
</Text>
<Text size="sm" c="dimmed">
Who attached, detached or switched which wagon on this train from
the builder and from its trips newest first.
the builder and from its trips newest first, with the reason
given for detaching off a scheduled run.
</Text>
</Stack>
</Group>
@@ -120,6 +130,17 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
</Group>
) : null}
</Group>
{entry.reason ? (
<Group gap={4} wrap="nowrap" align="flex-start" mt={4}>
<MessageSquare
size={12}
style={{ flexShrink: 0, marginTop: 3 }}
/>
<Text size="xs" c="dimmed" style={{ fontStyle: "italic" }}>
{entry.reason}
</Text>
</Group>
) : null}
</Timeline.Item>
);
})}

View File

@@ -4,6 +4,7 @@ import {
Badge,
Box,
Button,
Checkbox,
Group,
Modal,
Paper,
@@ -12,6 +13,7 @@ import {
Select,
Stack,
Text,
Textarea,
ThemeIcon,
Tooltip,
} from "@mantine/core";
@@ -44,6 +46,7 @@ import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { useToast } from "@/hooks/use-toast";
import type {
BookingWagonRow,
EligibleContainerBooking,
FreightType,
TrainScheduleDetail,
@@ -298,6 +301,12 @@ export function ScheduleWorkspacePanel({
);
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
// Per-wagon loading/unloading modal for one booking.
const [wagonModal, setWagonModal] = useState<{
bookingId: string;
ref: string;
phase: "load" | "unload";
} | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
// Pool → pick a same-day schedule with free wagons and place the booking there.
@@ -887,6 +896,25 @@ export function ScheduleWorkspacePanel({
</Button>
</Tooltip>
) : null}
{showLoad && boardHere ? (
<Tooltip
label="Load wagon by wagon — and cancel any wagon that will not ride"
withArrow
>
<Button
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
disabled={!canLoad || !loadWindowStarted}
onClick={() =>
setWagonModal({ bookingId: b.id, ref, phase: "load" })
}
>
Wagons
</Button>
</Tooltip>
) : null}
{showTruckToTrain ? (
<Tooltip
label={
@@ -950,6 +978,22 @@ export function ScheduleWorkspacePanel({
</Button>
</Tooltip>
) : null}
{showUnload && alightHere ? (
<Tooltip label="Unload wagon by wagon" withArrow>
<Button
size="compact-sm"
variant="light"
color="orange"
radius="md"
disabled={!canUnload || !unloadWindowStarted}
onClick={() =>
setWagonModal({ bookingId: b.id, ref, phase: "unload" })
}
>
Wagons
</Button>
</Tooltip>
) : null}
{canManage && !riding && !done ? (
journey?.isGovernment ? null : (
<Tooltip label="Remove from this train" withArrow>
@@ -984,6 +1028,18 @@ export function ScheduleWorkspacePanel({
</Group>
</Stack>
{/* Per-wagon load/unload for one booking */}
{wagonModal ? (
<PerWagonModal
scheduleId={schedule.id}
bookingId={wagonModal.bookingId}
reference={wagonModal.ref}
phase={wagonModal.phase}
onClose={() => setWagonModal(null)}
onChanged={onChanged}
/>
) : null}
{/* Pool → same-day train assignment modal */}
<Modal
opened={Boolean(poolAssign)}
@@ -1355,3 +1411,246 @@ function BookingCard({
</Paper>
);
}
/**
* Per-wagon loading/unloading of one booking. Load phase also offers the
* at-loading cancel of everything not yet loaded: the booking shrinks to its
* loaded wagons (CUSTOMER fault invoices the cancellation fee to pay after;
* EDR fault charges nothing) — required before the train may dispatch.
*/
function PerWagonModal({
scheduleId,
bookingId,
reference,
phase,
onClose,
onChanged,
}: {
scheduleId: string;
bookingId: string;
reference: string;
phase: "load" | "unload";
onClose: () => void;
onChanged: () => void;
}) {
const { toast } = useToast();
const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState("");
const [edrFault, setEdrFault] = useState(false);
const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({
input: { bookingId },
}));
const wagons: BookingWagonRow[] = wagonsQuery.data ?? [];
const isDone = (w: BookingWagonRow) =>
phase === "load"
? w.status === "LOADED" || w.status === "DEPARTED"
: w.status === "DEPARTED";
const doneCount = wagons.filter(isDone).length;
const pending = wagons.filter((w) => !isDone(w));
const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions());
const unloadWagon = useMutation(
api.trainScheduling.unloadScheduleBookingWagon.mutationOptions(),
);
const cancelRemaining = useMutation(
api.trainScheduling.cancelRemainingWagons.mutationOptions(),
);
const act = phase === "load" ? loadWagon : unloadWagon;
const errText = (err: unknown) =>
isAxiosError(err)
? ((err.response?.data as { message?: string })?.message ?? err.message)
: String(err);
const onWagon = (allocationId: string) => {
act
.mutateAsync({ scheduleId, bookingId, allocationId })
.then((r) => {
void wagonsQuery.refetch();
if (r.completed) {
toast({
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
description: `${reference}: every wagon is ${phase === "load" ? "loaded — the booking is in transit" : "unloaded — the booking arrived"}.`,
});
onChanged();
onClose();
} else {
onChanged();
}
})
.catch((err) =>
toast({
title: phase === "load" ? "Wagon load failed" : "Wagon unload failed",
description: errText(err),
variant: "destructive",
}),
);
};
const onCancelRemaining = () => {
cancelRemaining
.mutateAsync({ bookingId, scheduleId, reason: reason.trim(), edrFault })
.then(() => {
toast({
title: "Remaining wagons cancelled",
description: edrFault
? `${reference}: ${pending.length} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
: `${reference}: ${pending.length} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
});
onChanged();
onClose();
})
.catch((err) =>
toast({
title: "Cancellation failed",
description: errText(err),
variant: "destructive",
}),
);
};
return (
<Modal
opened
onClose={onClose}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
{phase === "load" ? "Load" : "Unload"} {reference} wagon by wagon
</Text>
</Group>
}
centered
radius="lg"
size="lg"
>
<Stack gap="sm">
<Group gap={8}>
<Badge size="sm" radius="sm" variant="light" color={doneCount ? "edr-green" : "gray"}>
{doneCount}/{wagons.length} {phase === "load" ? "loaded" : "unloaded"}
</Badge>
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
<Text size="xs" c="dimmed">
The train cannot dispatch until the rest are loaded or cancelled.
</Text>
) : null}
</Group>
{wagonsQuery.isLoading ? (
<Text size="sm" c="dimmed">
Loading wagons
</Text>
) : wagons.length === 0 ? (
<Text size="sm" c="dimmed">
No wagon allocations yet use the whole-booking button instead.
</Text>
) : (
wagons.map((w) => (
<Paper key={w.allocationId} withBorder radius="md" p="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Badge size="sm" radius="sm" variant="outline" color="gray">
{w.sequenceNo != null ? `#${w.sequenceNo}` : "—"}
</Badge>
<Text size="sm" fw={600} truncate>
{w.wagonNumber ?? w.wagonType ?? "Wagon"}
</Text>
<Text size="xs" c="dimmed">
{w.wagonTypeCode ?? ""}
{w.allocatedWeightTons
? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T`
: ""}
{w.containers?.length ? ` · ${w.containers.length} ctr` : ""}
</Text>
</Group>
{isDone(w) ? (
<Badge
size="sm"
radius="sm"
variant="filled"
color={phase === "load" ? "edr-green" : "orange"}
leftSection={<CheckCircle2 size={11} />}
>
{phase === "load" ? "Loaded" : "Unloaded"}
</Badge>
) : (
<Button
size="compact-sm"
variant="filled"
color={phase === "load" ? "edr-green" : "orange"}
radius="md"
leftSection={
phase === "load" ? <PackageCheck size={13} /> : <PackageOpen size={13} />
}
loading={
act.isPending && act.variables?.allocationId === w.allocationId
}
onClick={() => onWagon(w.allocationId)}
>
{phase === "load" ? "Load" : "Unload"}
</Button>
)}
</Group>
</Paper>
))
)}
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
!cancelOpen ? (
<Button
variant="light"
color="red"
radius="md"
leftSection={<X size={14} />}
onClick={() => setCancelOpen(true)}
>
Cancel the {pending.length} remaining wagon{pending.length === 1 ? "" : "s"}
</Button>
) : (
<Paper withBorder radius="md" p="sm">
<Stack gap="xs">
<Text size="sm" fw={600}>
Cancel {pending.length} unloaded wagon
{pending.length === 1 ? "" : "s"} of {reference}
</Text>
<Text size="xs" c="dimmed">
The booking shrinks to its loaded wagons and the freed freight
becomes a rebookable credit. Customer fault: the cancellation
fee is invoiced, payable afterwards. EDR fault: no fee.
</Text>
<Textarea
label="Reason"
placeholder="Why are these wagons not riding?"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
minRows={2}
required
/>
<Checkbox
label="EDR's fault (wagon shortage, yard problem) — charge no fee"
checked={edrFault}
onChange={(e) => setEdrFault(e.currentTarget.checked)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setCancelOpen(false)}>
Back
</Button>
<Button
color="red"
radius="md"
disabled={!reason.trim()}
loading={cancelRemaining.isPending}
onClick={onCancelRemaining}
>
Cancel wagons{edrFault ? " (no fee)" : " (fee applies)"}
</Button>
</Group>
</Stack>
</Paper>
)
) : null}
</Stack>
</Modal>
);
}

View File

@@ -493,6 +493,13 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
BOOKING_UNLOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
BOOKING_WAGON_LOAD: (id: string, bookingId: string, allocationId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/wagons/${allocationId}/load`,
BOOKING_WAGON_UNLOAD: (id: string, bookingId: string, allocationId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/wagons/${allocationId}/unload`,
BOOKING_WAGONS: (bookingId: string) => `/bookings/${bookingId}/wagons`,
CANCEL_REMAINING_WAGONS: (bookingId: string) =>
`/bookings/${bookingId}/wagon-cancellations/at-loading`,
INTERCITY_BOOKINGS: "/train-scheduling/intercity/bookings",
INTERCITY_CANDIDATES: (id: string) =>
`/train-scheduling/schedules/${id}/intercity-candidates`,

View File

@@ -90,17 +90,8 @@ export default function TrainBuilderDetailPage() {
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const [maintenanceNote, setMaintenanceNote] = useState("");
// Clearing the note with the target stops one wagon's reason being carried
// over onto the next wagon sent to maintenance.
const closeMaintenance = () => {
setMaintenanceTarget(null);
setMaintenanceNote("");
};
// Detach-approval flow: on a SCHEDULED run, detach/maintenance is filed as a
// request (with reason) and executed by a second staffer's approval.
// Every detach / maintenance move asks for a reason first — it is recorded
// as an auto-approved audit row and on the train's wagon history.
const [requestTarget, setRequestTarget] = useState<{
wagon: TrainCompositionWagon;
action: "DETACH" | "MAINTENANCE";
@@ -110,15 +101,8 @@ export default function TrainBuilderDetailPage() {
setRequestTarget(null);
setRequestReason("");
};
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
const [rejectNote, setRejectNote] = useState("");
const closeReject = () => {
setRejectTarget(null);
setRejectNote("");
};
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canApproveDetach = hasPermission(user, FREIGHT_PERMS.trains.approveWagonDetach);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
@@ -149,35 +133,17 @@ export default function TrainBuilderDetailPage() {
enabled: Boolean(id),
}),
);
const createDetachRequest = useMutation(
api.trainBuilder.createDetachRequest.mutationOptions(),
);
const approveDetachRequest = useMutation(
api.trainBuilder.approveDetachRequest.mutationOptions(),
);
const rejectDetachRequest = useMutation(
api.trainBuilder.rejectDetachRequest.mutationOptions(),
);
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// Approval kicks in once a run is SCHEDULED. DRAFT stays direct-edit; a
// dispatched train is frozen outright (composition.editable is false).
const requiresDetachApproval = (composition?.activeSchedules ?? []).some(
(s) => s.status === "SCHEDULED",
);
const detachRequests = useMemo(
() => detachRequestsQuery.data ?? [],
[detachRequestsQuery.data],
);
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
const pendingWagonIds = useMemo(
() => new Set(detachRequests.filter((r) => r.status === "PENDING").map((r) => r.wagonId)),
[detachRequests],
);
// The diagram memoizes off its `locomotives`/`wagons` props; building those
// arrays inline in JSX would hand it a new identity on every render and
@@ -270,32 +236,19 @@ export default function TrainBuilderDetailPage() {
[withToast, reorderWagons.mutateAsync, trainId],
);
const wagons = composition?.wagons;
const openDetachRequest = useCallback(
const openDetachReason = useCallback(
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
if (pendingWagonIds.has(wagonId)) {
toast({
title: "A detach request for this wagon is already pending approval",
});
return;
}
const wagon = wagons?.find((w) => w.id === wagonId);
if (wagon) setRequestTarget({ wagon, action });
},
[pendingWagonIds, wagons, toast],
[wagons],
);
const handleRemove = useCallback(
(wagonId: string) => {
if (!trainId) return;
if (requiresDetachApproval) {
openDetachRequest(wagonId, "DETACH");
return;
}
void withToast(
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
"Could not detach wagon",
);
openDetachReason(wagonId, "DETACH");
},
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
[trainId, openDetachReason],
);
const handleChangeWagonYard = useCallback(
(wagonId: string, currentYardId: string) => {
@@ -319,13 +272,9 @@ export default function TrainBuilderDetailPage() {
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => {
if (requiresDetachApproval) {
openDetachRequest(wagon.id, "MAINTENANCE");
return;
}
setMaintenanceTarget(wagon);
openDetachReason(wagon.id, "MAINTENANCE");
},
[requiresDetachApproval, openDetachRequest],
[openDetachReason],
);
if (compositionQuery.isLoading) {
@@ -502,9 +451,11 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{!composition.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run its composition is frozen until arrival.
{(composition.activeSchedules ?? []).some((s) => s.status === "DISPATCHED") ? (
<Alert color="blue" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run. You can still edit its composition
the dispatched run keeps the wagon plan it departed with, and your changes
apply to scheduled (not yet departed) runs only.
</Alert>
) : null}
@@ -563,7 +514,7 @@ export default function TrainBuilderDetailPage() {
))}
</Group>
{detachRequests.length ? (
{/* {detachRequests.length ? (
<Card>
<Stack gap="sm">
<Group justify="space-between">
@@ -576,8 +527,8 @@ export default function TrainBuilderDetailPage() {
</Group>
<Text size="xs" c="dimmed">
While this train is on a scheduled run, detaching a wagon (or sending it to
maintenance) needs a second staff member's approval. Decided requests stay
here as the audit trail.
maintenance) requires a reason — recorded here as the audit trail of who
did it and why.
</Text>
{detachRequests.map((req) => {
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
@@ -622,49 +573,9 @@ export default function TrainBuilderDetailPage() {
</Text>
) : null}
</Stack>
{req.status === "PENDING" && canApproveDetach ? (
<Group gap="xs" wrap="nowrap">
<Tooltip
label="You filed this request — a different staff member must approve it"
disabled={!isOwn}
withArrow
>
<Button
size="compact-sm"
color="green"
disabled={isOwn}
loading={approveDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await approveDetachRequest.mutateAsync({
id: composition.id,
requestId: req.id,
});
toast({
title: `Wagon ${req.wagonNumber} ${
req.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
}, "Could not approve request")
}
>
Approve
</Button>
</Tooltip>
<Button
size="compact-sm"
variant="light"
color="red"
onClick={() => setRejectTarget(req)}
>
Reject
</Button>
</Group>
) : req.status === "PENDING" ? (
{req.status === "PENDING" ? (
<Text size="xs" c="dimmed">
Awaiting approval
Legacy request — approval flow removed
</Text>
) : null}
</Group>
@@ -672,7 +583,7 @@ export default function TrainBuilderDetailPage() {
})}
</Stack>
</Card>
) : null}
) : null} */}
<Stack gap="sm">
<TrainCompositionDiagram
@@ -811,71 +722,14 @@ export default function TrainBuilderDetailPage() {
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={Boolean(maintenanceTarget)}
onClose={closeMaintenance}
title={<Text fw={600}>Send wagon to maintenance?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{maintenanceTarget?.wagonNumber}
</Text>{" "}
is detached from train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
and set to MAINTENANCE it stays out of the available pool until it
clears. The detach is stamped with the time and this train's run
numbers in the wagon's history.
</Text>
<Textarea
label="Note"
placeholder="Optional note (e.g. reason for maintenance)"
value={maintenanceNote}
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeMaintenance}>
Keep in consist
</Button>
<Button
color="orange"
leftSection={<Wrench size={16} />}
loading={maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: maintenanceTarget!.id,
note: maintenanceNote.trim() || undefined,
});
toast({
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
});
closeMaintenance();
}, "Could not send wagon to maintenance")
}
>
Send to maintenance
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(requestTarget)}
onClose={closeRequest}
title={
<Text fw={600}>
{requestTarget?.action === "MAINTENANCE"
? "Request maintenance approval?"
: "Request detach approval?"}
? "Send wagon to maintenance?"
: "Detach wagon?"}
</Text>
}
radius="lg"
@@ -883,22 +737,28 @@ export default function TrainBuilderDetailPage() {
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
is on a scheduled run, so wagon{" "}
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{requestTarget?.wagon.wagonNumber}
</Text>{" "}
is not detached now your request goes to a staff member with approval
rights, and the{" "}
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
happens the moment they approve it.
{requestTarget?.action === "MAINTENANCE"
? "leaves train "
: "is detached from train "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
{requestTarget?.action === "MAINTENANCE"
? "and is set to MAINTENANCE — it stays out of the available pool until it clears."
: "immediately."}{" "}
The reason is required and shows in this train&apos;s History tab.
</Text>
<Textarea
label="Reason"
placeholder="Why must this wagon leave the scheduled consist? (required)"
placeholder={
requestTarget?.action === "MAINTENANCE"
? "Why is this wagon going to maintenance? (required)"
: "Why is this wagon leaving the consist? (required)"
}
value={requestReason}
onChange={(e) => setRequestReason(e.currentTarget.value)}
autosize
@@ -919,73 +779,36 @@ export default function TrainBuilderDetailPage() {
)
}
disabled={!requestReason.trim()}
loading={createDetachRequest.isPending}
loading={removeWagon.isPending || maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await createDetachRequest.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
action: requestTarget!.action,
reason: requestReason.trim(),
});
if (requestTarget!.action === "MAINTENANCE") {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
note: requestReason.trim(),
});
} else {
await removeWagon.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
reason: requestReason.trim(),
});
}
toast({
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
title: `Wagon ${requestTarget!.wagon.wagonNumber} ${
requestTarget!.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
closeRequest();
}, "Could not file the request")
}, "Could not detach the wagon")
}
>
Request approval
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(rejectTarget)}
onClose={closeReject}
title={<Text fw={600}>Reject this request?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{rejectTarget?.wagonNumber}
</Text>{" "}
stays in the consist. The requester sees your note in the request history.
</Text>
<Textarea
label="Why is it rejected?"
placeholder="Required"
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
autosize
minRows={2}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeReject}>
Cancel
</Button>
<Button
color="red"
disabled={!rejectNote.trim()}
loading={rejectDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await rejectDetachRequest.mutateAsync({
id: composition.id,
requestId: rejectTarget!.id,
note: rejectNote.trim(),
});
toast({ title: `Request for wagon ${rejectTarget!.wagonNumber} rejected` });
closeReject();
}, "Could not reject the request")
}
>
Reject request
{requestTarget?.action === "MAINTENANCE"
? "Send to maintenance"
: "Detach wagon"}
</Button>
</Group>
</Stack>

View File

@@ -941,6 +941,52 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
loadScheduleBookingWagon: endpoint<
{ scheduleId: string; bookingId: string; allocationId: string },
import("@/types/trainScheduling").WagonLoadResult
>(
"train-scheduling",
"booking-wagon-load",
({ scheduleId, bookingId, allocationId }) =>
trainSchedulingService.loadScheduleBookingWagon(scheduleId, bookingId, allocationId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
unloadScheduleBookingWagon: endpoint<
{ scheduleId: string; bookingId: string; allocationId: string },
import("@/types/trainScheduling").WagonLoadResult
>(
"train-scheduling",
"booking-wagon-unload",
({ scheduleId, bookingId, allocationId }) =>
trainSchedulingService.unloadScheduleBookingWagon(scheduleId, bookingId, allocationId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
bookingWagons: endpoint<
{ bookingId: string },
import("@/types/trainScheduling").BookingWagonRow[]
>(
"train-scheduling",
"booking-wagons",
({ bookingId }) => trainSchedulingService.bookingWagons(bookingId),
({ bookingId }) => ["train-scheduling", "booking-wagons", bookingId],
),
cancelRemainingWagons: endpoint<
{ bookingId: string; scheduleId: string; reason: string; edrFault?: boolean },
unknown
>(
"train-scheduling",
"cancel-remaining-wagons",
({ bookingId, scheduleId, reason, edrFault }) =>
trainSchedulingService.cancelRemainingWagons(bookingId, { scheduleId, reason, edrFault }),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
intercityBookings: endpoint<
void,
import("@/types/trainScheduling").IntercityRideAlongRow[]
@@ -2245,11 +2291,14 @@ export const api = {
seedComposition,
),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
removeWagon: endpoint<
{ id: string; wagonId: string; reason?: string },
TrainComposition
>(
"train-builder",
"removeWagon",
({ id, wagonId }) =>
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
({ id, wagonId, reason }) =>
trainBuilderService.removeWagon(id, wagonId, reason).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
@@ -2276,43 +2325,6 @@ export const api = {
({ id }) => trainBuilderService.detachRequests(id).then((r) => r.data),
),
createDetachRequest: endpoint<
{ id: string; wagonId: string; action: "DETACH" | "MAINTENANCE"; reason: string },
WagonDetachRequestRow
>(
"train-builder",
"createDetachRequest",
({ id, wagonId, action, reason }) =>
trainBuilderService.createDetachRequest(id, wagonId, { action, reason }).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
approveDetachRequest: endpoint<
{ id: string; requestId: string; note?: string },
TrainComposition
>(
"train-builder",
"approveDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.approveDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
rejectDetachRequest: endpoint<
{ id: string; requestId: string; note: string },
TrainComposition
>(
"train-builder",
"rejectDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.rejectDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",

View File

@@ -311,6 +311,11 @@ export interface TrainHistoryEntry {
actor: string | null;
/** Set when the change came from a trip (schedule); null = train-builder edit. */
scheduleReference: string | null;
/**
* Why the wagon left the consist — required for a detach / maintenance move
* on a SCHEDULED run. Null for trip events and unscheduled builder edits.
*/
reason: string | null;
occurredAt: string;
}
@@ -436,37 +441,20 @@ export const trainBuilderService = {
}),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
/** `reason` is required by the API while the train is on a SCHEDULED run. */
removeWagon: (id: string, wagonId: string, reason?: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`, {
data: reason ? { reason } : undefined,
}),
/** Detach a wagon and move it to MAINTENANCE status. */
/** `note` is the maintenance reason — recorded with the train it came off. */
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
note,
}),
/** Requests to detach a wagon from a SCHEDULED train, newest first. */
/** Detach/maintenance audit rows of a SCHEDULED-run train, newest first. */
detachRequests: (id: string) =>
apiClient.get<WagonDetachRequestRow[]>(`${BASE}/${id}/detach-requests`),
/** File a detach/maintenance approval request (reason required). */
createDetachRequest: (
id: string,
wagonId: string,
payload: { action: "DETACH" | "MAINTENANCE"; reason: string },
) =>
apiClient.post<WagonDetachRequestRow>(
`${BASE}/${id}/wagons/${wagonId}/detach-requests`,
payload,
),
/** Approve a pending request — executes the detach immediately. */
approveDetachRequest: (id: string, requestId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/approve`, {
note,
}),
/** Reject a pending request — a note explaining why is required. */
rejectDetachRequest: (id: string, requestId: string, note: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/reject`, {
note,
}),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */

View File

@@ -11,6 +11,8 @@ import type {
BookingWindow,
AssignBookingsPayload,
BookingLoadResult,
BookingWagonRow,
WagonLoadResult,
BookingUnloadResult,
CompositionRemovalEntry,
DocReviewAlert,
@@ -510,6 +512,48 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
loadScheduleBookingWagon: async (
scheduleId: string,
bookingId: string,
allocationId: string,
): Promise<WagonLoadResult> => {
const response = await client.post<WagonLoadResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGON_LOAD(scheduleId, bookingId, allocationId),
{},
);
return unwrap(response.data);
},
unloadScheduleBookingWagon: async (
scheduleId: string,
bookingId: string,
allocationId: string,
): Promise<WagonLoadResult> => {
const response = await client.post<WagonLoadResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGON_UNLOAD(scheduleId, bookingId, allocationId),
{},
);
return unwrap(response.data);
},
bookingWagons: async (bookingId: string): Promise<BookingWagonRow[]> => {
const response = await client.get<BookingWagonRow[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGONS(bookingId),
);
return unwrap(response.data);
},
cancelRemainingWagons: async (
bookingId: string,
payload: { scheduleId: string; reason: string; edrFault?: boolean },
): Promise<unknown> => {
const response = await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_REMAINING_WAGONS(bookingId),
payload,
);
return unwrap(response.data);
},
listIntercityBookings: async (): Promise<
import("@/types/trainScheduling").IntercityRideAlongRow[]
> => {

View File

@@ -1206,6 +1206,34 @@ export interface BookingLoadResult {
loadedAt: string;
}
/** Per-wagon load/unload confirmation; `completed` = the booking finished with it. */
export interface WagonLoadResult {
bookingId: string;
allocationId: string;
status: string;
loadedWagons?: number;
unloadedWagons?: number;
totalWagons: number;
completed: boolean;
}
/** One allocated wagon of a booking, from GET /bookings/:id/wagons. */
export interface BookingWagonRow {
allocationId: string;
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
wagonTypeCode: string | null;
allocatedWeightTons: number | string | null;
loadType: string | null;
status: string;
containers: Array<{
containerNumber: string | null;
sizeFt: number | null;
grossWeightTons: number | string | null;
}>;
}
export interface BookingUnloadResult {
bookingId: string;
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */

View File

@@ -13,6 +13,7 @@ import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
@@ -41,6 +42,7 @@ import {
FileUp,
Flame,
MapPin,
MoveRight,
Package,
Receipt,
Repeat,
@@ -237,6 +239,19 @@ export default function NewShipmentPage() {
);
}
/**
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
* by the API from the cargo type's allowed wagon types. Undefined when no
* wagon type is configured — the wagon-count check then falls away.
*/
function bulkMaxTonsPerWagon(
contract: Freight.IContract,
): number | null | undefined {
return contract.cargoScope?.find(
(scope) => scope.cargoType?.maxTonsPerWagon != null,
)?.cargoType?.maxTonsPerWagon;
}
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
@@ -434,6 +449,7 @@ function NewShipmentBookingForm({
contract.freightType === "CONTAINER" &&
contract.equipmentReturn === "WITH_RETURN",
unitOfMeasure: bulkUnitOfMeasure(contract),
maxTonsPerWagon: bulkMaxTonsPerWagon(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
// Export completion locks onto a specific train — the pick is required
@@ -688,20 +704,20 @@ function NewShipmentBookingForm({
<Title
order={1}
fw={800}
fz={26}
fz={28}
style={{ letterSpacing: "-0.01em" }}
>
{completeBookingId
? isResubmit
? "Change Your Booking"
: "Complete Your Booking"
? "Change shipment booking"
: "Complete shipment booking"
: "New Shipment Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{completeBookingId
? isResubmit
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
: `Clearance is finalized. Enter cargo details and the binding shipment day to complete ${completeBooking?.reference ?? "this booking"} under contract ${contract.reference}.`
: `Book a shipment against contract ${contract.reference}.`}
</Text>
</Box>
@@ -791,17 +807,6 @@ function NewShipmentBookingForm({
}}
>
<Box className="mx-auto max-w-4xl">
{showValidationSummary ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : null}
{blockOdd20ft ? (
<Alert
color="red"
@@ -823,16 +828,42 @@ function NewShipmentBookingForm({
{`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`}
</Alert>
) : null}
<Group justify="flex-end">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{showValidationSummary && (
<>
<AlertCircle
size={15}
color="#C0392B"
style={{ flexShrink: 0 }}
/>
<Text fz={13} fw={500} c="#C0392B">
Fix the highlighted fields to review the price.
</Text>
</>
)}
</Group>
<Group gap="sm" wrap="nowrap">
<Button
type="button"
variant="default"
radius="md"
onClick={() =>
navigate(`/contracts/${contract.id}`)
}
>
Cancel
</Button>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Group>
</Group>
</Box>
</Box>
@@ -1212,15 +1243,45 @@ function RouteStep({
)}
/>
) : (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Text fz={14} fw={600} c="#10202F">
{routes[0]?.originYard?.label ?? "—"} {" "}
{routes[0]?.destinationYard?.label ?? "—"}
</Text>
<Text fz={12} c="dimmed" mt={2}>
<Group
wrap="nowrap"
gap={16}
align="center"
px={18}
py={18}
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
background: "#FBFCFD",
}}
>
<Box>
<Text fz={15} fw={700} c="#10202F">
{routes[0]?.originYard?.label ?? "—"}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
Origin yard
</Text>
</Box>
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
<Box>
<Text fz={15} fw={700} c="#10202F">
{routes[0]?.destinationYard?.label ?? "—"}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
Destination yard
</Text>
</Box>
<Box style={{ flex: 1 }} />
<Badge
variant="light"
color="teal"
radius={8}
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
>
{contract.tradeDirection}
</Text>
</Paper>
</Badge>
</Group>
)}
</StepCard>
);
@@ -1300,8 +1361,16 @@ function ScheduleStep({
const selectedTrainId = form.watch("trainScheduleId");
const isExportPick =
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
const requestedWagonsValue = form.watch("requestedWagons");
const wagonsEstimate = useMemo(() => {
if (contract.freightType !== "CONTAINER") return undefined;
// NUMBER_OF_WAGONS bulk states its wagon count outright — pass it through
// so the train picker sizes fits/free against the real need instead of
// falling back to the server's tonnage estimate.
if (contract.freightType !== "CONTAINER") {
if (bulkUnitOfMeasure(contract) !== "NUMBER_OF_WAGONS") return undefined;
const wagons = Math.floor(Number(requestedWagonsValue || 0));
return wagons >= 1 ? wagons : undefined;
}
const lines = containerLines ?? [];
const ft20 = lines
.filter((l) => l.containerSize === "20ft")
@@ -1311,7 +1380,7 @@ function ScheduleStep({
.reduce((s, l) => s + Number(l.quantity || 0), 0);
const wagons = Math.ceil(ft20 / 2) + ft40;
return wagons > 0 ? wagons : undefined;
}, [contract.freightType, containerLines]);
}, [contract, containerLines, requestedWagonsValue]);
const exportTrainsQuery = useQuery({
...api.bookings.getExportTrains.queryOptions({
input: {

View File

@@ -25,6 +25,13 @@ export interface ShipmentValidationContext {
*/
withReturnService?: boolean;
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS";
/**
* NUMBER_OF_WAGONS: the most tons one wagon of this cargo may carry. The
* requested count must spread the tonnage no heavier than this, or the
* server rejects the booking (assertWagonShareFits). Undefined when the
* cargo type has no wagon type configured — the check then falls away.
*/
maxTonsPerWagon?: number | null;
/**
* Intercity (DOMESTIC) shipments ride a passing import/export train that
* staff pick later, so no shipment day is chosen. Defaults to true.
@@ -312,6 +319,23 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
path: ["requestedWagons"],
message: "Enter the number of wagons needed (at least 1).",
});
} else {
// Too few wagons for the tonnage can never ride: 200T across 3
// wagons is 66.67T each on a 50T wagon. Mirrors the server's
// assertWagonShareFits so the button blocks before the API 400s.
const tons = Number(data.cargoWeightTons || 0);
const maxPerWagon = Number(ctx.maxTonsPerWagon || 0);
if (tons > 0 && maxPerWagon > 0 && tons / wagons > maxPerWagon) {
refineCtx.addIssue({
code: "custom",
path: ["requestedWagons"],
message:
`${tons} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
`${Math.round((tons / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
`this cargo carries at most ${maxPerWagon}T — request at least ` +
`${Math.ceil(tons / maxPerWagon)} wagons.`,
});
}
}
}

View File

@@ -179,6 +179,13 @@ export interface IContractCargoScope {
code?: string | null;
cargoTypeName?: string | null;
unitOfMeasure?: string | null;
/**
* Most tons of this cargo one wagon may carry, across the cargo's allowed
* wagon types (rated capacity, capped by the type's loading limit). Lets
* the booking forms reject a wagon count whose even share overloads a
* wagon before the server does. Null when no wagon type is configured.
*/
maxTonsPerWagon?: number | null;
} | null;
cargoFreeText?: string | null;
/**