|
|
|
|
@@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
|
|
|
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
|
|
|
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
|
|
|
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
|
|
|
|
import { requestedBulkWagons } from '../train-scheduling/train-capacity.util';
|
|
|
|
|
import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util';
|
|
|
|
|
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';
|
|
|
|
|
@@ -74,6 +76,8 @@ interface RequestedCut {
|
|
|
|
|
wagons: number;
|
|
|
|
|
weightTons: number;
|
|
|
|
|
quantities: CancelledQuantities;
|
|
|
|
|
/** The booking's whole wagon footprint the cut came out of — credit divides by it. */
|
|
|
|
|
totalWagons: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
|
|
|
|
|
@@ -165,7 +169,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
feePerWagon: fee.perWagon,
|
|
|
|
|
feeAmount: fee.amount,
|
|
|
|
|
feeCurrency: fee.currency,
|
|
|
|
|
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
|
|
|
|
creditAmount: round2(Number(booking.totalAmount ?? 0)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
this.assertCutSparesSharedWagon(cut);
|
|
|
|
|
@@ -177,7 +181,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
feePerWagon: fee.perWagon,
|
|
|
|
|
feeAmount: fee.amount,
|
|
|
|
|
feeCurrency: fee.currency,
|
|
|
|
|
creditAmount: this.creditFor(booking, cut.wagons),
|
|
|
|
|
creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -218,7 +222,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
: await this.resolveRequestedCut(booking, dto);
|
|
|
|
|
const fee = await this.priceFee(booking, cut);
|
|
|
|
|
const feeAmount = fee.amount;
|
|
|
|
|
const creditAmount = this.creditFor(booking, cut.wagons);
|
|
|
|
|
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
|
|
|
|
|
|
|
|
|
|
const row = await this.repo.create({
|
|
|
|
|
bookingId,
|
|
|
|
|
@@ -318,7 +322,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
|
|
|
|
|
where: { bookingId: row.bookingId },
|
|
|
|
|
});
|
|
|
|
|
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) {
|
|
|
|
|
if (rows < Math.round(await this.wagonFootprint(booking))) {
|
|
|
|
|
throw new ConflictException(
|
|
|
|
|
'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.',
|
|
|
|
|
);
|
|
|
|
|
@@ -363,7 +367,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
const row = await this.openConsolidationBreak(
|
|
|
|
|
booking,
|
|
|
|
|
'ceil',
|
|
|
|
|
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
|
|
|
|
round2(Number(booking.totalAmount ?? 0)),
|
|
|
|
|
reason ?? 'Consolidated pair cancelled',
|
|
|
|
|
userId,
|
|
|
|
|
);
|
|
|
|
|
@@ -371,7 +375,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
await this.openConsolidationBreak(
|
|
|
|
|
partner,
|
|
|
|
|
'floor',
|
|
|
|
|
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
|
|
|
|
|
round2(Number(partner.totalAmount ?? 0)),
|
|
|
|
|
`Cancelled with its consolidation partner ${booking.reference}`,
|
|
|
|
|
userId,
|
|
|
|
|
);
|
|
|
|
|
@@ -540,7 +544,8 @@ export class BookingWagonCancellationService {
|
|
|
|
|
} as RequestWagonCancellationDto);
|
|
|
|
|
}
|
|
|
|
|
return this.resolveRequestedCut(booking, {
|
|
|
|
|
wagons: Number(booking.wagonsRequired ?? 0),
|
|
|
|
|
// Footprint, not the live wagonsRequired: unassign clears that to NULL.
|
|
|
|
|
wagons: await this.wagonFootprint(booking),
|
|
|
|
|
} as RequestWagonCancellationDto);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -568,7 +573,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
const row = await this.openConsolidationBreak(
|
|
|
|
|
booking,
|
|
|
|
|
'ceil',
|
|
|
|
|
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
|
|
|
|
round2(Number(booking.totalAmount ?? 0)),
|
|
|
|
|
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
|
|
|
|
|
);
|
|
|
|
|
await this.dataSource.getRepository(Booking).update(booking.id, {
|
|
|
|
|
@@ -729,9 +734,10 @@ export class BookingWagonCancellationService {
|
|
|
|
|
// Whole-booking cut: nothing is left to ship, so the booking ends
|
|
|
|
|
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
|
|
|
|
|
// train. The credit row still points at it for T3.
|
|
|
|
|
const wagonsLeft = round2(
|
|
|
|
|
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
|
|
|
|
);
|
|
|
|
|
// Off the pinned footprint, not the live wagonsRequired — unassign
|
|
|
|
|
// clears that to NULL, which read as a full cut on any partial cancel.
|
|
|
|
|
const footprint = await this.wagonFootprint(booking);
|
|
|
|
|
const wagonsLeft = round2(footprint - 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
|
|
|
|
|
@@ -745,6 +751,9 @@ export class BookingWagonCancellationService {
|
|
|
|
|
: null;
|
|
|
|
|
await manager.getRepository(Booking).update(booking.id, {
|
|
|
|
|
wagonsRequired: Math.max(0, wagonsLeft),
|
|
|
|
|
// Keep the cancellation footprint in step, so a second partial cancel
|
|
|
|
|
// prices against what is actually left, not the original booking.
|
|
|
|
|
cancellationWagons: Math.max(0, wagonsLeft),
|
|
|
|
|
...(requestedWagonsLeft !== null
|
|
|
|
|
? { bulkRequestedWagons: requestedWagonsLeft }
|
|
|
|
|
: {}),
|
|
|
|
|
@@ -853,14 +862,37 @@ export class BookingWagonCancellationService {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Staff may cut a SUBSET of the never-loaded wagons (picked in the loading
|
|
|
|
|
// modal) instead of the whole remainder. Anything already LOADED is
|
|
|
|
|
// rejected rather than silently dropped: the operator believes they are
|
|
|
|
|
// cancelling that wagon, and it is on the train.
|
|
|
|
|
let target = remaining;
|
|
|
|
|
if (dto.wagonAllocationIds?.length) {
|
|
|
|
|
const wanted = new Set(dto.wagonAllocationIds);
|
|
|
|
|
const known = new Set(allocations.map((a) => a.id));
|
|
|
|
|
const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id));
|
|
|
|
|
if (unknown.length) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'Some selected wagons are not allocated to this booking on this schedule.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a));
|
|
|
|
|
if (loaded.length) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
`${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
target = remaining.filter((a) => wanted.has(a.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cut = await this.resolveRequestedCut(booking, {
|
|
|
|
|
wagonAllocationIds: remaining.map((r) => r.id),
|
|
|
|
|
wagonAllocationIds: target.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 creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
|
|
|
|
|
|
|
|
|
|
const row = await this.repo.create({
|
|
|
|
|
bookingId,
|
|
|
|
|
@@ -1253,7 +1285,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
booking: Booking,
|
|
|
|
|
dto: RequestWagonCancellationDto,
|
|
|
|
|
): Promise<RequestedCut> {
|
|
|
|
|
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
|
|
|
|
const totalWagons = await this.wagonFootprint(booking);
|
|
|
|
|
if (totalWagons <= 0) {
|
|
|
|
|
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
|
|
|
|
|
}
|
|
|
|
|
@@ -1335,6 +1367,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
weightTons: weightShare,
|
|
|
|
|
// Bookings without unit records fall back to the T2 LIFO trim.
|
|
|
|
|
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
|
|
|
|
|
totalWagons,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1360,7 +1393,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
if (tons <= 0) {
|
|
|
|
|
throw new BadRequestException('The requested cut is too small to release cargo.');
|
|
|
|
|
}
|
|
|
|
|
return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
|
|
|
|
|
return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -1416,6 +1449,7 @@ export class BookingWagonCancellationService {
|
|
|
|
|
wagons,
|
|
|
|
|
weightTons: tons,
|
|
|
|
|
quantities: { bulkTons: tons, allocationIds },
|
|
|
|
|
totalWagons,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1460,12 +1494,54 @@ export class BookingWagonCancellationService {
|
|
|
|
|
wagons,
|
|
|
|
|
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
|
|
|
|
|
quantities: { bySize, units, allocationIds },
|
|
|
|
|
totalWagons,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The booking's wagon footprint for cancellation pricing.
|
|
|
|
|
*
|
|
|
|
|
* `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so
|
|
|
|
|
* a paid booking pulled off a train read 0 wagons and could not be cancelled
|
|
|
|
|
* at all. `cancellationWagons` is stamped once at first allocation and never
|
|
|
|
|
* cleared — read it first. A booking never allocated has neither, so size it
|
|
|
|
|
* from the cargo the same way the scheduler would: TEU geometry for
|
|
|
|
|
* containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage
|
|
|
|
|
* ÷ wagon capacity for PER_TON bulk.
|
|
|
|
|
*/
|
|
|
|
|
private async wagonFootprint(booking: Booking): Promise<number> {
|
|
|
|
|
const pinned = Number(booking.cancellationWagons ?? 0);
|
|
|
|
|
if (pinned > 0) return round2(pinned);
|
|
|
|
|
const stored = Number(booking.wagonsRequired ?? 0);
|
|
|
|
|
if (stored > 0) return round2(stored);
|
|
|
|
|
|
|
|
|
|
const requested = requestedBulkWagons(booking);
|
|
|
|
|
if (requested > 0) return requested;
|
|
|
|
|
|
|
|
|
|
// Cargo relations drive the sizing — reload when the caller passed a bare
|
|
|
|
|
// booking (findById does not always hydrate them).
|
|
|
|
|
const full =
|
|
|
|
|
booking.bookingContainers || booking.cargoType
|
|
|
|
|
? booking
|
|
|
|
|
: ((await this.dataSource.getRepository(Booking).findOne({
|
|
|
|
|
where: { id: booking.id },
|
|
|
|
|
relations: {
|
|
|
|
|
bookingContainers: { containerType: true },
|
|
|
|
|
cargoType: { wagonTypes: true },
|
|
|
|
|
},
|
|
|
|
|
})) ?? booking);
|
|
|
|
|
const capacities = (full.cargoType?.wagonTypes ?? [])
|
|
|
|
|
.map((wt) => Number(wt.capacityTons))
|
|
|
|
|
.filter((c) => c > 0);
|
|
|
|
|
const bulkCapacity =
|
|
|
|
|
full.freightType === 'BULK' && capacities.length
|
|
|
|
|
? Math.max(...capacities)
|
|
|
|
|
: undefined;
|
|
|
|
|
return round2(wagonsRequiredForBooking(full, bulkCapacity));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
|
|
|
|
|
private creditFor(booking: Booking, wagons: number): number {
|
|
|
|
|
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
|
|
|
|
private creditFor(booking: Booking, wagons: number, totalWagons: number): number {
|
|
|
|
|
if (totalWagons <= 0) return 0;
|
|
|
|
|
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
|
|
|
|
|
}
|
|
|
|
|
|