mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
feat(train): enhance train history and scheduling features
- Added a reason field to train history entries for detach/maintenance actions. - Updated TrainHistoryPanel to display the reason for wagon detachments. - Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons. - Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason. - Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments. - Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
This commit is contained in:
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,3 +156,46 @@ describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
|
|||||||
)).toBe(0);
|
)).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 { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
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 { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
} from './booking-wagon-cancellations.repository';
|
} from './booking-wagon-cancellations.repository';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import {
|
import {
|
||||||
|
CancelRemainingWagonsDto,
|
||||||
RebookCancelledWagonsDto,
|
RebookCancelledWagonsDto,
|
||||||
RebookContainerLineDto,
|
RebookContainerLineDto,
|
||||||
RequestWagonCancellationDto,
|
RequestWagonCancellationDto,
|
||||||
@@ -625,7 +628,14 @@ export class BookingWagonCancellationService {
|
|||||||
this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`);
|
this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`);
|
||||||
return;
|
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
|
// The fee can settle after loading started (slow payment). Never cut
|
||||||
// loaded cargo: leave the row FEE_PENDING and alert staff to resolve
|
// loaded cargo: leave the row FEE_PENDING and alert staff to resolve
|
||||||
@@ -653,6 +663,25 @@ export class BookingWagonCancellationService {
|
|||||||
return;
|
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) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const booking = await manager.getRepository(Booking).findOne({
|
const booking = await manager.getRepository(Booking).findOne({
|
||||||
where: { id: row.bookingId },
|
where: { id: row.bookingId },
|
||||||
@@ -736,7 +765,7 @@ export class BookingWagonCancellationService {
|
|||||||
|
|
||||||
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
||||||
status: 'CREDIT_AVAILABLE',
|
status: 'CREDIT_AVAILABLE',
|
||||||
feePaidAt: new Date(),
|
...(opts.feeSettled ? { feePaidAt: new Date() } : {}),
|
||||||
weightTons: droppedWeight,
|
weightTons: droppedWeight,
|
||||||
cancelledQuantities: quantities,
|
cancelledQuantities: quantities,
|
||||||
});
|
});
|
||||||
@@ -754,9 +783,145 @@ export class BookingWagonCancellationService {
|
|||||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
|
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.log(
|
}
|
||||||
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
|
|
||||||
|
/**
|
||||||
|
* Staff cancel of the never-loaded remainder mid-load: the operator loaded
|
||||||
|
* what physically rides and cuts the rest, so the booking shrinks to its
|
||||||
|
* loaded wagons, dispatch unblocks, and the warehouse only ever sees the
|
||||||
|
* final (smaller) booking. Unlike the customer flow the cut applies
|
||||||
|
* IMMEDIATELY — the train cannot wait for a fee payment:
|
||||||
|
* - CUSTOMER fault: cancellation fee invoiced, payable after; the credit
|
||||||
|
* row opens right away (feePaidAt stamps when the invoice settles).
|
||||||
|
* - EDR fault: no fee at all; the credit is rebookable in full.
|
||||||
|
*/
|
||||||
|
async cancelRemainingAtLoading(
|
||||||
|
bookingId: string,
|
||||||
|
dto: CancelRemainingWagonsDto,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<BookingWagonCancellation> {
|
||||||
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`);
|
||||||
|
if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Only a paid booking still loading can cancel its remaining wagons.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (booking.loadedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This booking is already fully loaded — there is nothing left to cancel.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!booking.contractId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const open = await this.repo.findOpenForBooking(bookingId);
|
||||||
|
if (open) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allocations = await this.dataSource
|
||||||
|
.getRepository(WagonBookingAllocation)
|
||||||
|
.createQueryBuilder('alloc')
|
||||||
|
.innerJoin(TrainSetWagon, 'slot', 'slot.id = alloc.train_set_wagon_id')
|
||||||
|
.innerJoin(
|
||||||
|
TrainSchedule,
|
||||||
|
'schedule',
|
||||||
|
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||||
|
{ scheduleId: dto.scheduleId },
|
||||||
|
)
|
||||||
|
.where('alloc.booking_id = :bookingId', { bookingId })
|
||||||
|
.getMany();
|
||||||
|
const loaded = allocations.filter(
|
||||||
|
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
|
||||||
);
|
);
|
||||||
|
const remaining = allocations.filter(
|
||||||
|
(a) => a.status !== 'LOADED' && a.status !== 'DEPARTED',
|
||||||
|
);
|
||||||
|
if (!loaded.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Loading has not started for this booking — use the normal wagon cancellation flow.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!remaining.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Every wagon of this booking is loaded — there is nothing to cancel.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cut = await this.resolveRequestedCut(booking, {
|
||||||
|
wagonAllocationIds: remaining.map((r) => r.id),
|
||||||
|
} as RequestWagonCancellationDto);
|
||||||
|
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
||||||
|
|
||||||
|
const edrFault = !!dto.edrFault;
|
||||||
|
const fee = edrFault ? null : await this.priceFee(booking, cut);
|
||||||
|
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||||
|
|
||||||
|
const row = await this.repo.create({
|
||||||
|
bookingId,
|
||||||
|
wagonsCancelled: cut.wagons,
|
||||||
|
weightTons: cut.weightTons,
|
||||||
|
cancelledQuantities: cut.quantities,
|
||||||
|
creditAmount,
|
||||||
|
feeRateId: fee?.rates[0]?.id ?? null,
|
||||||
|
feeAmount: fee?.amount ?? 0,
|
||||||
|
feeCurrency: fee?.currency ?? booking.paymentCurrency ?? 'ETB',
|
||||||
|
status: 'FEE_PENDING',
|
||||||
|
reason: dto.reason,
|
||||||
|
fault: edrFault ? 'EDR' : 'CUSTOMER',
|
||||||
|
requestedByUserId: userId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let current = row;
|
||||||
|
if (fee && fee.amount > 0) {
|
||||||
|
const invoice = await this.billing.generateInvoice({
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: bookingId,
|
||||||
|
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
companyProfileId: booking.companyProfileId,
|
||||||
|
currency: fee.currency,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: 'CANCELLATION_FEE',
|
||||||
|
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference} cancelled at loading`,
|
||||||
|
quantity: cut.wagons,
|
||||||
|
unitRate: fee.perWagon,
|
||||||
|
amount: fee.amount,
|
||||||
|
currency: fee.currency,
|
||||||
|
metadata: { wagonCancellationId: row.id },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalAmount: fee.amount,
|
||||||
|
status: Freight.InvoiceStatus.Issued,
|
||||||
|
});
|
||||||
|
current = (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cut applies NOW — booking shrinks, allocations release, credit opens.
|
||||||
|
// EDR fault (or a zero fee) settles the fee side immediately; a customer-
|
||||||
|
// fault fee stays owed and stamps feePaidAt via onFeePaid when it settles.
|
||||||
|
await this.applyCut(current, false, { feeSettled: edrFault || !fee || fee.amount <= 0 });
|
||||||
|
|
||||||
|
// The booking now holds only loaded wagons — let the journey complete the
|
||||||
|
// load (PAID → IN_TRANSIT, warehouse inventory, milestones).
|
||||||
|
this.events.emit('booking.wagonsCancelledAtLoading', {
|
||||||
|
bookingId,
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
userId: userId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notifyStaff(
|
||||||
|
booking,
|
||||||
|
'Wagons cancelled at loading',
|
||||||
|
`${booking.reference}: ${cut.wagons} unloaded wagon(s) cancelled (${edrFault ? 'EDR fault — no fee' : `customer fault — fee invoiced`}). Reason: ${dto.reason}`,
|
||||||
|
);
|
||||||
|
return this.mustFind(row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1725,6 +1890,14 @@ export class BookingWagonCancellationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }];
|
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;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import { BookingWagonCancellationService } from "./booking-wagon-cancellation.se
|
|||||||
import {
|
import {
|
||||||
FilterWagonCancellationsDto,
|
FilterWagonCancellationsDto,
|
||||||
RebookCancelledWagonsDto,
|
RebookCancelledWagonsDto,
|
||||||
|
CancelRemainingWagonsDto,
|
||||||
RequestWagonCancellationDto,
|
RequestWagonCancellationDto,
|
||||||
} from "./dto/wagon-cancellation.dto";
|
} from "./dto/wagon-cancellation.dto";
|
||||||
import {
|
import {
|
||||||
@@ -640,6 +641,20 @@ export class BookingsController {
|
|||||||
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
|
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")
|
@Get(":id/wagon-cancellations")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Wagon-cancellation history of one booking (owner or staff)",
|
summary: "Wagon-cancellation history of one booking (owner or staff)",
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { Type } from 'class-transformer';
|
|||||||
import {
|
import {
|
||||||
ArrayNotEmpty,
|
ArrayNotEmpty,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -169,3 +171,28 @@ export class FilterWagonCancellationsDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
pageSize?: number;
|
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 })
|
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||||
reason?: string | null;
|
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 })
|
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||||
requestedByUserId?: string | null;
|
requestedByUserId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -588,6 +588,15 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||||
loadedAt?: Date | null;
|
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 })
|
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||||
loadedByUserId?: string | null;
|
loadedByUserId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
|||||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||||
yardId!: string | null;
|
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()' })
|
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||||
occurredAt!: Date;
|
occurredAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ export class WagonBookingAllocation extends BaseEntity {
|
|||||||
@Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true })
|
@Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true })
|
||||||
confirmedByUserId?: string | null;
|
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)
|
@OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation)
|
||||||
containerItems?: WagonAllocationContainerItem[];
|
containerItems?: WagonAllocationContainerItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
Optional,
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource, EntityManager, In } from 'typeorm';
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
import { Freight } from '@edr/types';
|
import { Freight } from '@edr/types';
|
||||||
@@ -72,6 +72,117 @@ export class BookingJourneyService {
|
|||||||
|
|
||||||
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
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') {
|
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
||||||
throw new BadRequestException('Booking is already loaded');
|
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,
|
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||||
// however it arrived and whatever it is allocated to.
|
// however it arrived and whatever it is allocated to.
|
||||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
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
|
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
|
||||||
// handover moment — the carriage acceptance sheet must go out to the
|
// handover moment — the carriage acceptance sheet must go out to the
|
||||||
// customer right here, not on a receive event that will never fire.
|
// 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) {
|
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
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') {
|
if (booking.status !== 'IN_TRANSIT') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
`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');
|
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||||
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
|
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
|
||||||
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
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/
|
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||||
// export continue into clearance, keyed on the booking's own arrival.
|
// export continue into clearance, keyed on the booking's own arrival.
|
||||||
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
||||||
@@ -498,7 +699,17 @@ export class BookingJourneyService {
|
|||||||
.findOne({ where: { id: bookingId } });
|
.findOne({ where: { id: bookingId } });
|
||||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
if (booking.trainScheduleId !== scheduleId) {
|
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 };
|
return { schedule, booking };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -707,6 +707,46 @@ export class TrainSchedulingController {
|
|||||||
return this.bookingJourneyService.unloadBooking(id, bookingId);
|
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")
|
@Post("schedules/:id/intercity/:bookingId/load")
|
||||||
@TrainSchedulingLoad()
|
@TrainSchedulingLoad()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -2946,6 +2946,10 @@ export class TrainSchedulingService {
|
|||||||
// The yard plan this departure was SOLD against must match where the steel
|
// 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.
|
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
|
||||||
await this.assertPlannedYardsAligned(schedule);
|
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) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
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
|
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
|
||||||
* (their charge sits on the credit ledger) yet ride from accept.
|
* (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(
|
private async unloadedOriginBoarderIds(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
@@ -3157,6 +3197,7 @@ export class TrainSchedulingService {
|
|||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
AND b.origin_yard_id = $2
|
AND b.origin_yard_id = $2
|
||||||
AND b.loaded_at IS NULL
|
AND b.loaded_at IS NULL
|
||||||
|
AND b.loading_started_at IS NULL
|
||||||
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
|
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
|
||||||
AND b.is_government = false
|
AND b.is_government = false
|
||||||
AND (b.status = 'PAID'
|
AND (b.status = 'PAID'
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class SendWagonToMaintenanceDto {
|
export class SendWagonToMaintenanceDto {
|
||||||
@ApiPropertyOptional({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"Why the wagon is going to maintenance. Stored on the wagon's status-history " +
|
"Why the wagon is going to maintenance — required. Stored on the wagon's " +
|
||||||
'log alongside the train it was detached from, matching the fleet desk flow.',
|
'status-history log alongside the train it was detached from, and on the ' +
|
||||||
|
"train's wagon-adjustment history.",
|
||||||
maxLength: 500,
|
maxLength: 500,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
note?: string;
|
note!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
|
/**
|
||||||
|
* Detach a wagon from the consist. The reason is always required — it is
|
||||||
export class CreateWagonDetachRequestDto {
|
* 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({
|
@ApiProperty({
|
||||||
enum: WagonDetachRequestAction,
|
description: 'Why the wagon leaves the consist — required.',
|
||||||
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.',
|
|
||||||
maxLength: 500,
|
maxLength: 500,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -20,14 +16,3 @@ export class CreateWagonDetachRequestDto {
|
|||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
reason!: string;
|
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 { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||||
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
||||||
import {
|
import { DetachWagonDto } from './dto/wagon-detach-request.dto';
|
||||||
CreateWagonDetachRequestDto,
|
|
||||||
DecideWagonDetachRequestDto,
|
|
||||||
} from './dto/wagon-detach-request.dto';
|
|
||||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||||
import { UpdateTrainYardDto } from './dto/update-train-yard.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.changeWagonYard,
|
||||||
FREIGHT_PERMS.trains.toggleActive,
|
FREIGHT_PERMS.trains.toggleActive,
|
||||||
FREIGHT_PERMS.trains.disband,
|
FREIGHT_PERMS.trains.disband,
|
||||||
FREIGHT_PERMS.trains.approveWagonDetach,
|
|
||||||
])
|
])
|
||||||
export class TrainBuilderController {
|
export class TrainBuilderController {
|
||||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||||
@@ -185,29 +181,38 @@ export class TrainBuilderController {
|
|||||||
|
|
||||||
@Delete(':id/wagons/:wagonId')
|
@Delete(':id/wagons/:wagonId')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
@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(
|
removeWagon(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@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')
|
@Post(':id/wagons/:wagonId/maintenance')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
@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(
|
sendWagonToMaintenance(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
@Body() dto?: SendWagonToMaintenanceDto,
|
@Body() dto: SendWagonToMaintenanceDto,
|
||||||
) {
|
) {
|
||||||
return this.trainBuilderService.sendWagonToMaintenance(
|
return this.trainBuilderService.sendWagonToMaintenance(
|
||||||
id,
|
id,
|
||||||
wagonId,
|
wagonId,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
dto?.note,
|
dto.note,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,65 +225,6 @@ export class TrainBuilderController {
|
|||||||
return this.trainBuilderService.listDetachRequests(id);
|
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')
|
@Post(':id/reorder-wagons')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
@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 { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.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 { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||||
import { Train } from './entities/train.entity';
|
import { Train } from './entities/train.entity';
|
||||||
import {
|
import {
|
||||||
@@ -253,6 +252,7 @@ export class TrainBuilderService {
|
|||||||
yardLabel: string | null;
|
yardLabel: string | null;
|
||||||
actor: string | null;
|
actor: string | null;
|
||||||
scheduleReference: string | null;
|
scheduleReference: string | null;
|
||||||
|
reason: string | null;
|
||||||
occurredAt: Date;
|
occurredAt: Date;
|
||||||
}>,
|
}>,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
@@ -270,6 +270,7 @@ export class TrainBuilderService {
|
|||||||
COALESCE(y.label, y.code) AS "yardLabel",
|
COALESCE(y.label, y.code) AS "yardLabel",
|
||||||
COALESCE(u.username, u.email) AS "actor",
|
COALESCE(u.username, u.email) AS "actor",
|
||||||
ts.reference AS "scheduleReference",
|
ts.reference AS "scheduleReference",
|
||||||
|
l.reason,
|
||||||
l.occurred_at AS "occurredAt"
|
l.occurred_at AS "occurredAt"
|
||||||
FROM freight.schedule_wagon_adjustment_logs l
|
FROM freight.schedule_wagon_adjustment_logs l
|
||||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||||
@@ -491,8 +492,9 @@ export class TrainBuilderService {
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
activeSchedules: schedules,
|
activeSchedules: schedules,
|
||||||
// Composition is frozen while the train is out on a dispatched run.
|
// The built train is always editable — dispatched/arrived runs render from
|
||||||
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
|
// their frozen snapshot, so consist edits reach only DRAFT/SCHEDULED runs.
|
||||||
|
editable: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,22 +756,40 @@ export class TrainBuilderService {
|
|||||||
return this.getComposition(id);
|
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) => {
|
const pending = await this.dataSource.transaction(async (manager) => {
|
||||||
await this.assertDetachNeedsNoApproval(manager, id);
|
await this.recordDetachReason(
|
||||||
return this.removeWagonCore(manager, id, wagonId, userId);
|
manager,
|
||||||
|
id,
|
||||||
|
wagonId,
|
||||||
|
WagonDetachRequestAction.Detach,
|
||||||
|
reason,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return this.removeWagonCore(manager, id, wagonId, userId, reason);
|
||||||
});
|
});
|
||||||
await this.reconcileWindowAfterConsistChange(pending);
|
await this.reconcileWindowAfterConsistChange(pending);
|
||||||
return this.getComposition(id);
|
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(
|
private async removeWagonCore(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
id: string,
|
id: string,
|
||||||
wagonId: string,
|
wagonId: string,
|
||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
|
reason?: string | null,
|
||||||
): Promise<PendingWindowCheck | null> {
|
): Promise<PendingWindowCheck | null> {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
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 }],
|
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||||
userId ?? null,
|
userId ?? null,
|
||||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||||
|
reason,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,8 +826,16 @@ export class TrainBuilderService {
|
|||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
note?: string | null,
|
note?: string | null,
|
||||||
) {
|
) {
|
||||||
|
// `note` is the required reason — recordDetachReason rejects it empty.
|
||||||
const pending = await this.dataSource.transaction(async (manager) => {
|
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);
|
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
|
||||||
});
|
});
|
||||||
await this.reconcileWindowAfterConsistChange(pending);
|
await this.reconcileWindowAfterConsistChange(pending);
|
||||||
@@ -883,96 +912,54 @@ export class TrainBuilderService {
|
|||||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||||
userId ?? null,
|
userId ?? null,
|
||||||
yardId,
|
yardId,
|
||||||
|
note,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Direct-detach guard: while this train carries a live SCHEDULED run,
|
* Every detach / send-to-maintenance carries a REASON — scheduled run or
|
||||||
* removing a wagon changes a departure customers already booked against, so
|
* not — and an auto-approved wagon_detach_requests row records who did it
|
||||||
* it is a two-person action — refuse here and point at the request flow.
|
* and why (the audit trail that replaced the former second-staff approval).
|
||||||
* DRAFT stays freely editable; DISPATCHED is already frozen by
|
* DISPATCHED trains never reach here: getEditableTrain freezes them.
|
||||||
* getEditableTrain (the train is IN_SERVICE).
|
|
||||||
*/
|
*/
|
||||||
private async assertDetachNeedsNoApproval(
|
private async recordDetachReason(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
trainId: string,
|
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,
|
wagonId: string,
|
||||||
dto: CreateWagonDetachRequestDto,
|
action: WagonDetachRequestAction,
|
||||||
|
reason: string | null | undefined,
|
||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
) {
|
): Promise<void> {
|
||||||
return this.dataSource.transaction(async (manager) => {
|
const trimmed = reason?.trim();
|
||||||
const train = await this.getEditableTrain(manager, id);
|
if (!trimmed) {
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
throw new BadRequestException(
|
||||||
if (!wagon || wagon.trainId !== train.id) {
|
`Give a reason for ${
|
||||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
action === WagonDetachRequestAction.Maintenance
|
||||||
}
|
? 'sending this wagon to maintenance'
|
||||||
const scheduled = await this.findScheduledRun(manager, train.id);
|
: 'detaching this wagon'
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
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) {
|
async listDetachRequests(trainId: string) {
|
||||||
const rows: Array<{
|
const rows: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1012,68 +999,6 @@ export class TrainBuilderService {
|
|||||||
return rows;
|
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),
|
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
||||||
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
||||||
@@ -1113,6 +1038,9 @@ export class TrainBuilderService {
|
|||||||
wagon: Wagon,
|
wagon: Wagon,
|
||||||
opts: { checkOnly?: boolean } = {},
|
opts: { checkOnly?: boolean } = {},
|
||||||
): Promise<void> {
|
): 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 }[] =
|
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
|
||||||
await manager.query(
|
await manager.query(
|
||||||
`SELECT tsw.id, tsw.train_set_id, ts.status,
|
`SELECT tsw.id, tsw.train_set_id, ts.status,
|
||||||
@@ -1123,15 +1051,18 @@ export class TrainBuilderService {
|
|||||||
FROM freight.train_set_wagons tsw
|
FROM freight.train_set_wagons tsw
|
||||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||||
WHERE tsw.physical_wagon_id = $1
|
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 ts.deleted_at IS NULL
|
||||||
AND tsw.deleted_at IS NULL`,
|
AND tsw.deleted_at IS NULL`,
|
||||||
[wagon.id],
|
[wagon.id],
|
||||||
);
|
);
|
||||||
if (!rows.length) return;
|
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(
|
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;
|
if (opts.checkOnly) return;
|
||||||
@@ -1162,25 +1093,9 @@ export class TrainBuilderService {
|
|||||||
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
||||||
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
||||||
}
|
}
|
||||||
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
|
// Reorder is allowed at any time, dispatched runs included: a DISPATCHED
|
||||||
// is allowed — the pinned schedules' consists are resequenced below so
|
// schedule renders the order frozen in its snapshot, and only the
|
||||||
// they can never desync from the built train's real order.
|
// DRAFT/SCHEDULED consists resequenced below follow the built train.
|
||||||
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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
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 }>,
|
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
||||||
userId: string | null,
|
userId: string | null,
|
||||||
yardId: string | null,
|
yardId: string | null,
|
||||||
|
reason?: string | null,
|
||||||
): Promise<PendingWindowCheck | null> {
|
): Promise<PendingWindowCheck | null> {
|
||||||
if (!changes.length) return null;
|
if (!changes.length) return null;
|
||||||
const trainSet = await manager
|
const trainSet = await manager
|
||||||
@@ -1443,6 +1359,7 @@ export class TrainBuilderService {
|
|||||||
wagonNumber: c.wagonNumber,
|
wagonNumber: c.wagonNumber,
|
||||||
adjustedByUserId: userId,
|
adjustedByUserId: userId,
|
||||||
yardId,
|
yardId,
|
||||||
|
reason: reason?.trim() || null,
|
||||||
occurredAt: now,
|
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. */
|
/** 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> {
|
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
||||||
const train = await manager.getRepository(Train).findOne({
|
const train = await manager.getRepository(Train).findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
lock: { mode: 'pessimistic_write' },
|
lock: { mode: 'pessimistic_write' },
|
||||||
});
|
});
|
||||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
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;
|
return train;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
@@ -49,7 +58,8 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Who attached, detached or switched which wagon on this train — from
|
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>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -120,6 +130,17 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
|
|||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</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>
|
</Timeline.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
@@ -44,6 +46,7 @@ import { api } from "@/services/api";
|
|||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type {
|
import type {
|
||||||
|
BookingWagonRow,
|
||||||
EligibleContainerBooking,
|
EligibleContainerBooking,
|
||||||
FreightType,
|
FreightType,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
@@ -298,6 +301,12 @@ export function ScheduleWorkspacePanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
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);
|
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
// Pool → pick a same-day schedule with free wagons and place the booking there.
|
// Pool → pick a same-day schedule with free wagons and place the booking there.
|
||||||
@@ -887,6 +896,25 @@ export function ScheduleWorkspacePanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : 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 ? (
|
{showTruckToTrain ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
@@ -950,6 +978,22 @@ export function ScheduleWorkspacePanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : 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 ? (
|
{canManage && !riding && !done ? (
|
||||||
journey?.isGovernment ? null : (
|
journey?.isGovernment ? null : (
|
||||||
<Tooltip label="Remove from this train" withArrow>
|
<Tooltip label="Remove from this train" withArrow>
|
||||||
@@ -984,6 +1028,18 @@ export function ScheduleWorkspacePanel({
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</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 */}
|
{/* Pool → same-day train assignment modal */}
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(poolAssign)}
|
opened={Boolean(poolAssign)}
|
||||||
@@ -1355,3 +1411,246 @@ function BookingCard({
|
|||||||
</Paper>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -491,6 +491,13 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
|
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
|
||||||
BOOKING_UNLOAD: (id: string, bookingId: string) =>
|
BOOKING_UNLOAD: (id: string, bookingId: string) =>
|
||||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
|
`/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_BOOKINGS: "/train-scheduling/intercity/bookings",
|
||||||
INTERCITY_CANDIDATES: (id: string) =>
|
INTERCITY_CANDIDATES: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||||
|
|||||||
@@ -90,17 +90,8 @@ export default function TrainBuilderDetailPage() {
|
|||||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||||
const [maintenanceTarget, setMaintenanceTarget] =
|
// Every detach / maintenance move asks for a reason first — it is recorded
|
||||||
useState<TrainCompositionWagon | null>(null);
|
// as an auto-approved audit row and on the train's wagon history.
|
||||||
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.
|
|
||||||
const [requestTarget, setRequestTarget] = useState<{
|
const [requestTarget, setRequestTarget] = useState<{
|
||||||
wagon: TrainCompositionWagon;
|
wagon: TrainCompositionWagon;
|
||||||
action: "DETACH" | "MAINTENANCE";
|
action: "DETACH" | "MAINTENANCE";
|
||||||
@@ -110,15 +101,8 @@ export default function TrainBuilderDetailPage() {
|
|||||||
setRequestTarget(null);
|
setRequestTarget(null);
|
||||||
setRequestReason("");
|
setRequestReason("");
|
||||||
};
|
};
|
||||||
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
|
|
||||||
const [rejectNote, setRejectNote] = useState("");
|
|
||||||
const closeReject = () => {
|
|
||||||
setRejectTarget(null);
|
|
||||||
setRejectNote("");
|
|
||||||
};
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
|
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 canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
|
||||||
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
|
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
|
||||||
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
|
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
|
||||||
@@ -149,35 +133,17 @@ export default function TrainBuilderDetailPage() {
|
|||||||
enabled: Boolean(id),
|
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 disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
||||||
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
|
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
|
||||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||||
|
|
||||||
const composition = compositionQuery.data;
|
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(
|
const detachRequests = useMemo(
|
||||||
() => detachRequestsQuery.data ?? [],
|
() => detachRequestsQuery.data ?? [],
|
||||||
[detachRequestsQuery.data],
|
[detachRequestsQuery.data],
|
||||||
);
|
);
|
||||||
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
|
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
|
// The diagram memoizes off its `locomotives`/`wagons` props; building those
|
||||||
// arrays inline in JSX would hand it a new identity on every render and
|
// 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],
|
[withToast, reorderWagons.mutateAsync, trainId],
|
||||||
);
|
);
|
||||||
const wagons = composition?.wagons;
|
const wagons = composition?.wagons;
|
||||||
const openDetachRequest = useCallback(
|
const openDetachReason = useCallback(
|
||||||
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
|
(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);
|
const wagon = wagons?.find((w) => w.id === wagonId);
|
||||||
if (wagon) setRequestTarget({ wagon, action });
|
if (wagon) setRequestTarget({ wagon, action });
|
||||||
},
|
},
|
||||||
[pendingWagonIds, wagons, toast],
|
[wagons],
|
||||||
);
|
);
|
||||||
const handleRemove = useCallback(
|
const handleRemove = useCallback(
|
||||||
(wagonId: string) => {
|
(wagonId: string) => {
|
||||||
if (!trainId) return;
|
if (!trainId) return;
|
||||||
if (requiresDetachApproval) {
|
openDetachReason(wagonId, "DETACH");
|
||||||
openDetachRequest(wagonId, "DETACH");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void withToast(
|
|
||||||
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
|
|
||||||
"Could not detach wagon",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
|
[trainId, openDetachReason],
|
||||||
);
|
);
|
||||||
const handleChangeWagonYard = useCallback(
|
const handleChangeWagonYard = useCallback(
|
||||||
(wagonId: string, currentYardId: string) => {
|
(wagonId: string, currentYardId: string) => {
|
||||||
@@ -319,13 +272,9 @@ export default function TrainBuilderDetailPage() {
|
|||||||
);
|
);
|
||||||
const handleMaintenance = useCallback(
|
const handleMaintenance = useCallback(
|
||||||
(wagon: TrainCompositionWagon) => {
|
(wagon: TrainCompositionWagon) => {
|
||||||
if (requiresDetachApproval) {
|
openDetachReason(wagon.id, "MAINTENANCE");
|
||||||
openDetachRequest(wagon.id, "MAINTENANCE");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMaintenanceTarget(wagon);
|
|
||||||
},
|
},
|
||||||
[requiresDetachApproval, openDetachRequest],
|
[openDetachReason],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (compositionQuery.isLoading) {
|
if (compositionQuery.isLoading) {
|
||||||
@@ -502,9 +451,11 @@ export default function TrainBuilderDetailPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!composition.editable ? (
|
{(composition.activeSchedules ?? []).some((s) => s.status === "DISPATCHED") ? (
|
||||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
<Alert color="blue" icon={<AlertTriangle size={16} />}>
|
||||||
This train is out on a dispatched run — its composition is frozen until arrival.
|
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>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -563,7 +514,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{detachRequests.length ? (
|
{/* {detachRequests.length ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
@@ -576,8 +527,8 @@ export default function TrainBuilderDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
While this train is on a scheduled run, detaching a wagon (or sending it to
|
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
|
maintenance) requires a reason — recorded here as the audit trail of who
|
||||||
here as the audit trail.
|
did it and why.
|
||||||
</Text>
|
</Text>
|
||||||
{detachRequests.map((req) => {
|
{detachRequests.map((req) => {
|
||||||
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
|
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
|
||||||
@@ -622,49 +573,9 @@ export default function TrainBuilderDetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
{req.status === "PENDING" && canApproveDetach ? (
|
{req.status === "PENDING" ? (
|
||||||
<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" ? (
|
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Awaiting approval
|
Legacy request — approval flow removed
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -672,7 +583,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null} */}
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<TrainCompositionDiagram
|
<TrainCompositionDiagram
|
||||||
@@ -811,71 +722,14 @@ export default function TrainBuilderDetailPage() {
|
|||||||
onClose={() => setYardModalOpen(false)}
|
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
|
<Modal
|
||||||
opened={Boolean(requestTarget)}
|
opened={Boolean(requestTarget)}
|
||||||
onClose={closeRequest}
|
onClose={closeRequest}
|
||||||
title={
|
title={
|
||||||
<Text fw={600}>
|
<Text fw={600}>
|
||||||
{requestTarget?.action === "MAINTENANCE"
|
{requestTarget?.action === "MAINTENANCE"
|
||||||
? "Request maintenance approval?"
|
? "Send wagon to maintenance?"
|
||||||
: "Request detach approval?"}
|
: "Detach wagon?"}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
@@ -883,22 +737,28 @@ export default function TrainBuilderDetailPage() {
|
|||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Train{" "}
|
Wagon{" "}
|
||||||
<Text span fw={700} c="dark">
|
|
||||||
{trainRunLabel}
|
|
||||||
</Text>{" "}
|
|
||||||
is on a scheduled run, so wagon{" "}
|
|
||||||
<Text span fw={700} ff="monospace" c="dark">
|
<Text span fw={700} ff="monospace" c="dark">
|
||||||
{requestTarget?.wagon.wagonNumber}
|
{requestTarget?.wagon.wagonNumber}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
is not detached now — your request goes to a staff member with approval
|
{requestTarget?.action === "MAINTENANCE"
|
||||||
rights, and the{" "}
|
? "leaves train "
|
||||||
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
|
: "is detached from train "}
|
||||||
happens the moment they approve it.
|
<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>
|
</Text>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Reason"
|
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}
|
value={requestReason}
|
||||||
onChange={(e) => setRequestReason(e.currentTarget.value)}
|
onChange={(e) => setRequestReason(e.currentTarget.value)}
|
||||||
autosize
|
autosize
|
||||||
@@ -919,73 +779,36 @@ export default function TrainBuilderDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={!requestReason.trim()}
|
disabled={!requestReason.trim()}
|
||||||
loading={createDetachRequest.isPending}
|
loading={removeWagon.isPending || maintenanceWagon.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void withToast(async () => {
|
void withToast(async () => {
|
||||||
await createDetachRequest.mutateAsync({
|
if (requestTarget!.action === "MAINTENANCE") {
|
||||||
id: composition.id,
|
await maintenanceWagon.mutateAsync({
|
||||||
wagonId: requestTarget!.wagon.id,
|
id: composition.id,
|
||||||
action: requestTarget!.action,
|
wagonId: requestTarget!.wagon.id,
|
||||||
reason: requestReason.trim(),
|
note: requestReason.trim(),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await removeWagon.mutateAsync({
|
||||||
|
id: composition.id,
|
||||||
|
wagonId: requestTarget!.wagon.id,
|
||||||
|
reason: requestReason.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
toast({
|
toast({
|
||||||
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
|
title: `Wagon ${requestTarget!.wagon.wagonNumber} ${
|
||||||
|
requestTarget!.action === "MAINTENANCE"
|
||||||
|
? "sent to maintenance"
|
||||||
|
: "detached"
|
||||||
|
}`,
|
||||||
});
|
});
|
||||||
closeRequest();
|
closeRequest();
|
||||||
}, "Could not file the request")
|
}, "Could not detach the wagon")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Request approval
|
{requestTarget?.action === "MAINTENANCE"
|
||||||
</Button>
|
? "Send to maintenance"
|
||||||
</Group>
|
: "Detach wagon"}
|
||||||
</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
|
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -940,6 +940,52 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => 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<
|
intercityBookings: endpoint<
|
||||||
void,
|
void,
|
||||||
import("@/types/trainScheduling").IntercityRideAlongRow[]
|
import("@/types/trainScheduling").IntercityRideAlongRow[]
|
||||||
@@ -2244,11 +2290,14 @@ export const api = {
|
|||||||
seedComposition,
|
seedComposition,
|
||||||
),
|
),
|
||||||
|
|
||||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
removeWagon: endpoint<
|
||||||
|
{ id: string; wagonId: string; reason?: string },
|
||||||
|
TrainComposition
|
||||||
|
>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"removeWagon",
|
"removeWagon",
|
||||||
({ id, wagonId }) =>
|
({ id, wagonId, reason }) =>
|
||||||
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
|
trainBuilderService.removeWagon(id, wagonId, reason).then((r) => r.data),
|
||||||
undefined,
|
undefined,
|
||||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||||
seedComposition,
|
seedComposition,
|
||||||
@@ -2275,43 +2324,6 @@ export const api = {
|
|||||||
({ id }) => trainBuilderService.detachRequests(id).then((r) => r.data),
|
({ 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>(
|
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"reorderWagons",
|
"reorderWagons",
|
||||||
|
|||||||
@@ -311,6 +311,11 @@ export interface TrainHistoryEntry {
|
|||||||
actor: string | null;
|
actor: string | null;
|
||||||
/** Set when the change came from a trip (schedule); null = train-builder edit. */
|
/** Set when the change came from a trip (schedule); null = train-builder edit. */
|
||||||
scheduleReference: string | null;
|
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;
|
occurredAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,37 +441,20 @@ export const trainBuilderService = {
|
|||||||
}),
|
}),
|
||||||
assignWagons: (id: string, wagonIds: string[]) =>
|
assignWagons: (id: string, wagonIds: string[]) =>
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||||
removeWagon: (id: string, wagonId: string) =>
|
/** `reason` is required by the API while the train is on a SCHEDULED run. */
|
||||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
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. */
|
/** Detach a wagon and move it to MAINTENANCE status. */
|
||||||
/** `note` is the maintenance reason — recorded with the train it came off. */
|
/** `note` is the maintenance reason — recorded with the train it came off. */
|
||||||
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
|
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
|
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
|
||||||
note,
|
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) =>
|
detachRequests: (id: string) =>
|
||||||
apiClient.get<WagonDetachRequestRow[]>(`${BASE}/${id}/detach-requests`),
|
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[]) =>
|
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||||
/** Park the train indefinitely — only allowed with no active schedule. */
|
/** Park the train indefinitely — only allowed with no active schedule. */
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
BookingWindow,
|
BookingWindow,
|
||||||
AssignBookingsPayload,
|
AssignBookingsPayload,
|
||||||
BookingLoadResult,
|
BookingLoadResult,
|
||||||
|
BookingWagonRow,
|
||||||
|
WagonLoadResult,
|
||||||
BookingUnloadResult,
|
BookingUnloadResult,
|
||||||
CompositionRemovalEntry,
|
CompositionRemovalEntry,
|
||||||
DocReviewAlert,
|
DocReviewAlert,
|
||||||
@@ -510,6 +512,48 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
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<
|
listIntercityBookings: async (): Promise<
|
||||||
import("@/types/trainScheduling").IntercityRideAlongRow[]
|
import("@/types/trainScheduling").IntercityRideAlongRow[]
|
||||||
> => {
|
> => {
|
||||||
|
|||||||
@@ -1206,6 +1206,34 @@ export interface BookingLoadResult {
|
|||||||
loadedAt: string;
|
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 {
|
export interface BookingUnloadResult {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
|
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
|
||||||
|
|||||||
Reference in New Issue
Block a user