mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 16:00:56 +00:00
Merge pull request #1434 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[]
|
||||
> => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user