mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ import { BookingWagonCancellationService } from "./booking-wagon-cancellation.se
|
||||
import {
|
||||
FilterWagonCancellationsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
CancelRemainingWagonsDto,
|
||||
RequestWagonCancellationDto,
|
||||
} from "./dto/wagon-cancellation.dto";
|
||||
import {
|
||||
@@ -640,6 +641,20 @@ export class BookingsController {
|
||||
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(":id/wagon-cancellations/at-loading")
|
||||
@BookingStaff(FREIGHT_PERMS.trainScheduling.load)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Staff: cancel the never-loaded remainder of a booking mid-load. The cut applies immediately (the train cannot wait); CUSTOMER fault invoices the fee to pay after, EDR fault charges nothing.",
|
||||
})
|
||||
async cancelRemainingWagonsAtLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelRemainingWagonsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.wagonCancellationService.cancelRemainingAtLoading(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Get(":id/wagon-cancellations")
|
||||
@ApiOperation({
|
||||
summary: "Wagon-cancellation history of one booking (owner or staff)",
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -169,3 +171,28 @@ export class FilterWagonCancellationsDto {
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel of the never-loaded remainder of a booking mid-load: everything
|
||||
* not yet LOADED on the schedule is cut, the booking shrinks to its loaded
|
||||
* wagons, and the freed credit is rebookable. Fault decides the fee: CUSTOMER
|
||||
* — cancellation fee invoiced (payable after the cut); EDR — no fee.
|
||||
*/
|
||||
export class CancelRemainingWagonsDto {
|
||||
@ApiProperty({ description: 'Schedule the booking is being loaded on' })
|
||||
@IsUUID('4')
|
||||
scheduleId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'The shortfall is EDR\'s fault (wagon shortage, yard problem) — no fee charged',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
edrFault?: boolean;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,15 @@ export class BookingWagonCancellation extends BaseEntity {
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
/**
|
||||
* At-loading cancels of the never-loaded remainder: who caused it.
|
||||
* CUSTOMER — cancellation fee applies (invoice payable after the cut);
|
||||
* EDR — no fee, the full credit is rebookable. Null for customer-requested
|
||||
* cancellations (the pre-loading flow).
|
||||
*/
|
||||
@Column({ name: 'fault', type: 'varchar', length: 16, nullable: true })
|
||||
fault?: 'CUSTOMER' | 'EDR' | null;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -588,6 +588,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* First wagon of this booking confirmed loaded (per-wagon loading). The
|
||||
* booking stays PAID until every remaining wagon is LOADED — loadedAt then
|
||||
* stamps the completion. Also shields the booking from the dispatch
|
||||
* "left behind" unassign while mid-load.
|
||||
*/
|
||||
@Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true })
|
||||
loadingStartedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||
loadedByUserId?: string | null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user