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