Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-28 11:08:35 +00:00
93 changed files with 5301 additions and 2035 deletions

View File

@@ -1,6 +1,10 @@
import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
bulkTonWagonsRequired,
bulkTonsPerWagonFor,
} from '../train-scheduling/train-capacity.util';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
@@ -107,3 +111,91 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
).rejects.toThrow(/already shares a wagon/i);
});
});
/**
* A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and
* bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it
* alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking
* re-inflates to its pre-cancel count on the next allocation and each wagon
* carries tons / stale-count instead of the real even share.
*/
describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
// 980T over 14 wagons (70T each), 2 wagons cancelled.
const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 };
const droppedWeight = 140;
const wagonsCancelled = 2;
// The decrement applied in applyPaidCut's booking update.
const after = {
...before,
cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight,
bulkRequestedWagons: Math.max(
0,
Math.floor(before.bulkRequestedWagons - wagonsCancelled),
),
};
it('reallocates at the reduced count, not the pre-cancel one', () => {
expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14);
expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12);
});
it('keeps tons-per-wagon at the real even share', () => {
// Stale count would spread 840T over 14 wagons → 60T each.
expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70);
});
it('cancelling every wagon leaves no requested count behind', () => {
const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14));
expect(all).toBe(0);
expect(bulkTonWagonsRequired(
{ ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all },
undefined,
'nw5',
70,
)).toBe(0);
});
});
/**
* Rebooking a NUMBER_OF_WAGONS bulk credit: the create path rejects the rebook
* unless the DTO carries a wagon count ("<cargo> is booked by wagons — enter
* the number of wagons needed"), and the quantities snapshot holds tons only.
* The count therefore has to come off the cancellation row itself.
*/
describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () => {
const svc = Object.create(BookingWagonCancellationService.prototype) as {
buildRebookDto(
row: unknown,
scheduledDate: string,
overrides?: unknown,
): { bulkLines?: { cargoWeightTons: number }[]; requestedWagons?: number };
};
it('carries the cancelled wagon count onto the rebook', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 2, weightTons: 140, cancelledQuantities: { bulkTons: 140 } },
'2026-09-10',
);
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 140 }]);
// Without this the create path throws before the booking is ever made.
expect(dto.requestedWagons).toBe(2);
});
it('rounds a fractional cut up to a whole wagon', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 0.5, weightTons: 35, cancelledQuantities: { bulkTons: 35 } },
'2026-09-10',
);
// Flooring would send 0 into a check that demands >= 1.
expect(dto.requestedWagons).toBe(1);
});
it('leaves the count off when nothing was cancelled', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 0, weightTons: 0, cancelledQuantities: { bulkTons: 12 } },
'2026-09-10',
);
expect(dto.requestedWagons).toBeUndefined();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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