fix wagon cncellation

This commit is contained in:
Marshal
2026-08-07 06:31:25 +00:00
parent 750dfc0720
commit 3db14bc09a
7 changed files with 501 additions and 19 deletions

View File

@@ -222,6 +222,27 @@ export class BookingWagonCancellationService {
}
if (row.status !== 'FEE_PENDING') return;
// The fee can settle after loading started (slow payment). Never cut
// loaded cargo: leave the row FEE_PENDING and alert staff to resolve
// (reschedule the cut or refund the fee by hand).
const bookingNow = await this.bookingsRepository.findById(row.bookingId);
const movingNow = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId: row.bookingId, status: In(['LOADED', 'DEPARTED']) },
});
if (bookingNow?.loadedAt || movingNow > 0) {
this.logger.error(
`Wagon cancellation ${row.id}: fee paid but loading already started on booking ${row.bookingId} — left FEE_PENDING for manual resolution.`,
);
if (bookingNow) {
this.notifyStaff(
bookingNow,
'Wagon cancellation fee paid after loading started',
`${bookingNow.reference}: the customer paid the cancellation fee for ${row.wagonsCancelled} wagon(s), but loading has already started. Resolve manually (adjust the cut or refund the fee).`,
);
}
return;
}
await this.dataSource.transaction(async (manager) => {
const booking = await manager.getRepository(Booking).findOne({
where: { id: row.bookingId },
@@ -233,7 +254,11 @@ export class BookingWagonCancellationService {
let droppedWeight = 0;
if (quantities.bySize && Object.keys(quantities.bySize).length) {
const units = await this.reduceContainerLines(manager, booking, quantities.bySize);
// Specific-wagon requests already carry the exact unit snapshots;
// quantity requests trim LIFO and snapshot here.
const units = quantities.units?.length
? await this.reduceContainerUnitsExact(manager, booking, quantities.units)
: await this.reduceContainerLines(manager, booking, quantities.bySize);
quantities.units = units;
droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await this.releaseContainerAllocations(
@@ -244,7 +269,12 @@ export class BookingWagonCancellationService {
} else {
droppedWeight = Number(quantities.bulkTons ?? row.weightTons);
await this.reduceBulk(manager, booking, droppedWeight);
await this.releaseBulkAllocations(manager, booking.id, Number(row.wagonsCancelled));
await this.releaseBulkAllocations(
manager,
booking.id,
Number(row.wagonsCancelled),
quantities.allocationIds,
);
}
// Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME
@@ -391,6 +421,14 @@ export class BookingWagonCancellationService {
'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).',
);
}
// Cancellation is allowed strictly BEFORE loading/dispatch: both signals
// checked — per-wagon allocation status and the booking-level loading stamp
// (some flows confirm loading on the booking without flipping allocations).
if (booking.loadedAt) {
throw new BadRequestException(
'Cargo loading is confirmed for this booking — wagons can no longer be cancelled.',
);
}
const moving = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId, status: In(['LOADED', 'DEPARTED']) },
});
@@ -412,6 +450,10 @@ export class BookingWagonCancellationService {
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
}
if (dto.wagonAllocationIds?.length) {
return this.resolveCutFromAllocations(booking, dto.wagonAllocationIds, totalWagons);
}
if (booking.freightType === 'CONTAINER') {
if (!dto.containers?.length) {
throw new BadRequestException('Specify the container units to cancel per size.');
@@ -471,6 +513,102 @@ export class BookingWagonCancellationService {
return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
}
/**
* Specific-wagon cancellation: the customer picked wagons in the Wagons tab.
* Everything is derived from the selected allocations — container bookings
* get their exact unit snapshots up front (T2 then cuts precisely these,
* not a LIFO guess), bulk gets the wagons' actual allocated tonnage.
*/
private async resolveCutFromAllocations(
booking: Booking,
allocationIds: string[],
totalWagons: number,
): Promise<RequestedCut> {
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
where: { id: In(allocationIds), bookingId: booking.id },
relations: { containerItems: true },
});
if (allocations.length !== allocationIds.length) {
throw new BadRequestException(
'Some selected wagons no longer belong to this booking — refresh and pick again.',
);
}
const notCancellable = allocations.filter(
(a) => a.status !== 'PLANNED' && a.status !== 'RESERVED',
);
if (notCancellable.length) {
throw new BadRequestException(
'A selected wagon is already loaded or departed and cannot be cancelled.',
);
}
const wagons = allocations.length;
if (wagons >= totalWagons) {
throw new BadRequestException(
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
);
}
if (booking.freightType !== 'CONTAINER') {
const allocated = allocations.reduce(
(s, a) => s + Number(a.allocatedWeightTons || 0),
0,
);
const tons =
allocated > 0
? round3(allocated)
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
return {
wagons,
weightTons: tons,
quantities: { bulkTons: tons, allocationIds },
};
}
// Container: the selected wagons' items name the exact physical boxes.
const numbers = allocations
.flatMap((a) => a.containerItems ?? [])
.map((i) => i.containerNumber)
.filter((n): n is string => !!n);
if (!numbers.length) {
throw new BadRequestException(
'The selected wagons carry no container records — cancel by quantity instead.',
);
}
const lines = await this.dataSource.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const units: CancelledUnitSnapshot[] = [];
const bySize: Record<string, number> = {};
for (const line of lines) {
const size = line.containerSize ?? '';
const lineUnits = await unitRepo.find({ where: { bookingContainerId: line.id } });
for (const u of lineUnits) {
if (!numbers.includes(u.containerNumber)) continue;
units.push({
containerSize: size,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
});
bySize[size] = (bySize[size] ?? 0) + 1;
}
}
if (units.length !== numbers.length) {
throw new BadRequestException(
'Wagon container records are out of sync with the booking — contact EDR support.',
);
}
return {
wagons,
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
quantities: { bySize, units, allocationIds },
};
}
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
private creditFor(booking: Booking, wagons: number): number {
const totalWagons = Number(booking.wagonsRequired ?? 0);
@@ -569,6 +707,62 @@ export class BookingWagonCancellationService {
return snapshots;
}
/**
* Cut EXACTLY the snapshotted units (specific-wagon cancellation): soft-delete
* them and rebalance each affected line. Returns the snapshots of the units
* actually cut, so drift since the request fails loudly instead of guessing.
*/
private async reduceContainerUnitsExact(
manager: EntityManager,
booking: Booking,
wanted: CancelledUnitSnapshot[],
): Promise<CancelledUnitSnapshot[]> {
const numbers = wanted.map((u) => u.containerNumber);
const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const cut: CancelledUnitSnapshot[] = [];
for (const line of lines) {
const size = line.containerSize ?? '';
const lineUnits = await manager.getRepository(BookingContainerUnit).find({
where: { bookingContainerId: line.id },
});
const doomed = lineUnits.filter((u) => numbers.includes(u.containerNumber));
if (!doomed.length) continue;
await manager.getRepository(BookingContainerUnit).softDelete(doomed.map((u) => u.id));
for (const u of doomed) {
cut.push({
containerSize: size,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
});
}
const kept = lineUnits.filter((u) => !numbers.includes(u.containerNumber));
if (!kept.length) {
await manager.getRepository(BookingContainer).softDelete(line.id);
continue;
}
const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await manager.getRepository(BookingContainer).update(line.id, {
quantity: kept.length,
wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm),
hazardousQuantity: kept.filter((u) => u.isHazardous).length,
reeferQuantity: kept.filter((u) => u.isReefer).length,
});
}
if (cut.length !== wanted.length) {
throw new BadRequestException(
`Booking changed since the request: ${cut.length}/${wanted.length} selected container(s) still on it.`,
);
}
return cut;
}
private async reduceBulk(
manager: EntityManager,
booking: Booking,
@@ -623,19 +817,37 @@ export class BookingWagonCancellationService {
}
}
/** Free whole bulk wagons, newest allocations first. */
/**
* Free whole bulk wagons — the customer-picked allocations when given
* (specific-wagon cancel), topping up newest-first for any picked id that no
* longer exists (re-batch between request and fee payment).
*/
private async releaseBulkAllocations(
manager: EntityManager,
bookingId: string,
wagons: number,
pickedIds?: string[],
): Promise<void> {
const toFree = Math.round(wagons);
if (toFree <= 0) return;
const allocations = await manager.getRepository(WagonBookingAllocation).find({
where: { bookingId },
order: { createdAt: 'DESC' },
take: toFree,
});
let allocations: WagonBookingAllocation[] = [];
if (pickedIds?.length) {
allocations = await manager.getRepository(WagonBookingAllocation).find({
where: { id: In(pickedIds), bookingId },
});
}
if (allocations.length < toFree) {
const have = new Set(allocations.map((a) => a.id));
const fill = await manager.getRepository(WagonBookingAllocation).find({
where: { bookingId },
order: { createdAt: 'DESC' },
});
for (const a of fill) {
if (allocations.length >= toFree) break;
if (!have.has(a.id)) allocations.push(a);
}
}
allocations = allocations.slice(0, toFree);
if (!allocations.length) return;
const ids = allocations.map((a) => a.id);
await manager

View File

@@ -347,7 +347,8 @@ export class BookingsService {
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
return this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
`SELECT a.id AS "allocationId",
tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
wt.code AS "wagonTypeCode",

View File

@@ -9,6 +9,7 @@ import {
IsNumber,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateNested,
@@ -28,6 +29,18 @@ export class CancelContainerLineDto {
}
export class RequestWagonCancellationDto {
@ApiPropertyOptional({
description:
'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' +
'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonAllocationIds?: string[];
@ApiPropertyOptional({
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
})

View File

@@ -37,12 +37,19 @@ export interface CancelledQuantities {
/** Container bookings: units cut per container size. */
bySize?: Record<string, number>;
/**
* Container bookings: the exact physical units cut, snapshotted at fee
* settlement. The rebook reconstructs the new booking from THESE — never
* Container bookings: the exact physical units cut. Snapshotted at request
* time when the customer picked specific wagons, otherwise at fee settlement
* (LIFO trim). The rebook reconstructs the new booking from THESE — never
* from a soft-deleted-row scan, which could pick up units dropped by an
* unrelated batch split on the same booking.
*/
units?: CancelledUnitSnapshot[];
/**
* Specific-wagon cancellation: the wagon_booking_allocation ids the customer
* picked in the Wagons tab. T2 releases exactly these (fallback to
* newest-first for any id that no longer exists, e.g. after a re-batch).
*/
allocationIds?: string[];
}
/**