fix wagon cncellation

This commit is contained in:
Marshal
2026-08-07 06:59:31 +00:00
parent 3db14bc09a
commit 296878cbde
7 changed files with 313 additions and 34 deletions

View File

@@ -20,6 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
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';
@@ -96,6 +97,8 @@ export class BookingWagonCancellationService {
private readonly clearanceMilestones: ClearanceMilestoneService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainScheduling: TrainSchedulingService,
@Inject(forwardRef(() => FirstMileService))
private readonly firstMile: FirstMileService,
private readonly inbox: NotificationInboxService,
@@ -185,7 +188,24 @@ export class BookingWagonCancellationService {
totalAmount: feeAmount,
status: Freight.InvoiceStatus.Issued,
});
const updated = await this.repo.update(row.id, { feeInvoiceId: invoice.id });
let updated = await this.repo.update(row.id, { feeInvoiceId: invoice.id });
// Policy: the cancelled wagons leave the schedule NOW — capacity frees for
// other customers immediately; the fee is still owed before the credit can
// be rebooked. A withdraw/void re-allocates (or errors when the train has
// no room left). If this release fails, T2 releases instead (flag unset).
try {
const released = await this.releaseAtRequest(bookingId, cut);
if (released) {
updated = await this.repo.update(row.id, {
cancelledQuantities: { ...cut.quantities, releasedAtRequest: true },
});
}
} catch (err) {
this.logger.error(
`Request-time wagon release failed for cancellation ${row.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
this.notifyStaff(
booking,
@@ -195,7 +215,13 @@ export class BookingWagonCancellationService {
return updated ?? row;
}
/** Void a FEE_PENDING request: fee invoice cancelled, nothing was released. */
/**
* Void a FEE_PENDING request (customer withdraw or staff void). The wagons
* left the schedule at request time, so voiding must first put them back:
* the schedule's auto-allocation is re-run and the result verified — if the
* train has no room left, the void FAILS with a clear error and the request
* stays FEE_PENDING (pay the fee and rebook the credit instead).
*/
async withdraw(cancellationId: string): Promise<BookingWagonCancellation> {
const row = await this.mustFind(cancellationId);
if (row.status !== 'FEE_PENDING') {
@@ -203,6 +229,31 @@ export class BookingWagonCancellationService {
`Only a fee-pending cancellation can be withdrawn (status is ${row.status}).`,
);
}
if (row.cancelledQuantities.releasedAtRequest) {
const booking = await this.bookingsRepository.findById(row.bookingId);
const scheduleId = booking?.trainScheduleId;
if (booking && scheduleId) {
try {
await this.trainScheduling.tryAutoWagonAllocation(scheduleId);
} catch (err) {
this.logger.warn(
`Re-allocation on withdraw failed for booking ${row.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
// ponytail: allocation rows ≈ wagons (20ft pairs share one row/wagon);
// switch to a weight-based check if mixed loads ever make this lie.
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId: row.bookingId },
});
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) {
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.',
);
}
}
}
if (row.feeInvoiceId) await this.billing.cancelInvoice(row.feeInvoiceId);
return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!;
}
@@ -224,12 +275,17 @@ export class BookingWagonCancellationService {
// 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).
// (reschedule the cut or refund the fee by hand). Skipped when the wagons
// already left the schedule at request time — loading of the KEPT wagons
// is then irrelevant to this cut.
const releasedEarly = !!row.cancelledQuantities.releasedAtRequest;
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) {
const movingNow = releasedEarly
? 0
: await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId: row.bookingId, status: In(['LOADED', 'DEPARTED']) },
});
if (!releasedEarly && (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.`,
);
@@ -261,20 +317,24 @@ export class BookingWagonCancellationService {
: 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(
manager,
booking.id,
units.map((u) => u.containerNumber),
);
if (!releasedEarly) {
await this.releaseContainerAllocations(
manager,
booking.id,
units.map((u) => u.containerNumber),
);
}
} else {
droppedWeight = Number(quantities.bulkTons ?? row.weightTons);
await this.reduceBulk(manager, booking, droppedWeight);
await this.releaseBulkAllocations(
manager,
booking.id,
Number(row.wagonsCancelled),
quantities.allocationIds,
);
if (!releasedEarly) {
await this.releaseBulkAllocations(
manager,
booking.id,
Number(row.wagonsCancelled),
quantities.allocationIds,
);
}
}
// Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME
@@ -484,10 +544,48 @@ export class BookingWagonCancellationService {
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
);
}
// Snapshot the LIFO-picked physical units up front (read-only — cargo is
// cut only when the fee settles) so the wagons carrying them can be
// released from the schedule at request time and the portal can show
// which containers are leaving.
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const units: CancelledUnitSnapshot[] = [];
let requested = 0;
for (const cut of dto.containers) {
requested += cut.quantity;
let need = cut.quantity;
const sizeLines = lines
.filter((l) => (l.containerSize ?? '') === cut.containerSize)
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
for (const line of sizeLines) {
if (need <= 0) break;
const us = await unitRepo.find({
where: { bookingContainerId: line.id },
order: { sortOrder: 'DESC', createdAt: 'DESC' },
take: need,
});
for (const u of us) {
units.push({
containerSize: cut.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
});
need--;
}
}
}
const weightShare = round3(
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
);
return { wagons, weightTons: weightShare, quantities: { bySize } };
return {
wagons,
weightTons: weightShare,
// Bookings without unit records fall back to the T2 LIFO trim.
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
};
}
// BULK: the customer cancels wagons; tons follow the booking's own
@@ -707,6 +805,42 @@ export class BookingWagonCancellationService {
return snapshots;
}
/**
* Release the cancelled wagons from the schedule at REQUEST time. Returns
* true when something was actually released (booking was on a train) — the
* caller then stamps `releasedAtRequest` so T2 skips its release step.
*/
private async releaseAtRequest(bookingId: string, cut: RequestedCut): Promise<boolean> {
const had = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId },
});
if (had === 0) return false;
await this.dataSource.transaction(async (manager) => {
if (cut.quantities.units?.length) {
await this.releaseContainerAllocations(
manager,
bookingId,
cut.quantities.units.map((u) => u.containerNumber),
);
} else if (!cut.quantities.bySize) {
await this.releaseBulkAllocations(
manager,
bookingId,
cut.wagons,
cut.quantities.allocationIds,
);
}
// Container booking without unit records: nothing to match on — the
// wagons release at T2 via the LIFO trim instead.
});
const left = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId },
});
return left < had;
}
/**
* Cut EXACTLY the snapshotted units (specific-wagon cancellation): soft-delete
* them and rebalance each affected line. Returns the snapshots of the units

View File

@@ -50,6 +50,13 @@ export interface CancelledQuantities {
* newest-first for any id that no longer exists, e.g. after a re-batch).
*/
allocationIds?: string[];
/**
* The wagon allocations were already released from the schedule at REQUEST
* time (policy: wagons free up immediately; the fee is still owed before the
* credit can be rebooked). Tells T2 to skip its release step so it never
* deletes wagons the batch engine re-assigned in between.
*/
releasedAtRequest?: boolean;
}
/**