mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
fix wagon cncellation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)',
|
||||
})
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user