feat(train): enhance train history and scheduling features

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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