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;
}
/**

View File

@@ -225,7 +225,13 @@ export function ExportClearanceStepper({
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
// Description is the only part of a passed step that stays visible,
// so it carries the assigned officer's name for GL Ethiopia.
description={
clearance.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "Ask GL Djibouti to name the officer handling this shipment"
}
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />

View File

@@ -357,7 +357,14 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
// Once the flow moves past this step its content collapses — the
// description is the only slot that stays visible, so it carries
// the assigned officer's name for GL Ethiopia.
description={
clearance.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "Ask GL Djibouti to name the officer handling this shipment"
}
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />

View File

@@ -79,7 +79,7 @@ const th = { color: "#9AA8B5", fontSize: 11 } as const;
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
* previewed first), pay the cancellation fee, then rebook the freed credit
* onto another shipment day — plus the booking's cancellation history.
* Wagons stay allocated until the fee invoice settles.
* Wagons leave the schedule at request time; the fee settles the credit.
*/
export function WagonCancellationCard({
booking,
@@ -251,9 +251,10 @@ export function WagonCancellationCard({
<Text span fw={700}>
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
</Text>
. Your wagons stay allocated until the fee is paid pay it to
release them and unlock the rebooking credit, or withdraw the
request to keep the booking as it is.
. The cancelled wagons have left the train. Pay the fee to unlock
the rebooking credit, or withdraw the request to get the wagons
back withdrawing works only while the train still has free space
for them.
</Alert>
<Group gap={8}>
<Button

View File

@@ -30,6 +30,7 @@ import toast from "react-hot-toast";
import {
bookingsService,
type BookingWagonAllocation,
type WagonCancellation,
type WagonCancellationPreview,
} from "@/services/bookings.service";
import { api } from "@/services/api";
@@ -37,6 +38,105 @@ import { api } from "@/services/api";
import { fmtDate, fmtWeight } from "../utils";
import { CardTitle, SectionCard } from "./layout";
const CANCEL_TONES: Record<
string,
{ bg: string; color: string; border: string; label: string; hint: string }
> = {
FEE_PENDING: {
bg: "#FFFBEB",
color: "#92400E",
border: "#FDE68A",
label: "Cancelled — fee unpaid",
hint: "These wagons left the train. Pay the cancellation fee to turn them into a rebooking credit, or withdraw to get them back (needs free space).",
},
CREDIT_AVAILABLE: {
bg: "#E6F7F2",
color: "#0A6F4D",
border: "#B7E6D6",
label: "Cancelled — credit ready",
hint: "Fee paid. Pick a new shipment day in the wagon cancellation card to rebook these — no new freight charge.",
},
REBOOKED: {
bg: "#E8F5EF",
color: "#0A6F4D",
border: "#B7E6D6",
label: "Rebooked",
hint: "These wagons ride again on the rebooked shipment.",
},
};
/** Cancelled wagons + their cargo, categorized by cancellation state. */
function CancelledWagonsSection({ rows }: { rows: WagonCancellation[] }) {
const visible = rows.filter((r) => CANCEL_TONES[r.status]);
if (!visible.length) return null;
return (
<SectionCard>
<CardTitle>Cancelled wagons</CardTitle>
<Stack gap="sm" mt="sm">
{visible.map((r) => {
const tone = CANCEL_TONES[r.status];
const units = r.cancelledQuantities.units ?? [];
return (
<Box
key={r.id}
p={12}
style={{
borderRadius: 10,
backgroundColor: tone.bg,
border: `1px solid ${tone.border}`,
}}
>
<Group justify="space-between" align="center" wrap="wrap" gap={6}>
<Text fz={13.5} fw={800} style={{ color: tone.color }}>
{Number(r.wagonsCancelled)} wagon(s) {tone.label}
</Text>
<Text fz={12} c="#6B7C8E">
{fmtDate(r.createdAt)}
{r.rebookedBooking
? ` · new booking ${r.rebookedBooking.reference}`
: ""}
</Text>
</Group>
{units.length > 0 && (
<Group gap={6} mt={6} wrap="wrap">
{units.map((u) => (
<Text
key={u.containerNumber}
component="span"
fz={11.5}
fw={700}
px={8}
py={2}
style={{
borderRadius: 6,
backgroundColor: "white",
border: `1px solid ${tone.border}`,
color: tone.color,
fontFamily: "monospace",
}}
>
{u.containerNumber} · {u.containerSize}ft
</Text>
))}
</Group>
)}
{!units.length && r.cancelledQuantities.bulkTons != null && (
<Text fz={12.5} mt={4} style={{ color: tone.color }}>
{Number(r.cancelledQuantities.bulkTons).toLocaleString()} tons of
bulk cargo
</Text>
)}
<Text fz={12} c="#6B7C8E" mt={6}>
{tone.hint}
</Text>
</Box>
);
})}
</Stack>
</SectionCard>
);
}
const apiErrorMessage = (error: unknown, fallback: string) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
@@ -377,13 +477,17 @@ export function WagonsTab({
enabled: !!bookingId,
});
// Only needed to block a second request while one is awaiting its fee.
// Cancellation history: feeds the "Cancelled wagons" section and blocks a
// second request while one is awaiting its fee.
const { data: history } = useQuery({
...api.bookings.listWagonCancellations.queryOptions({ input: { bookingId } }),
enabled: !!bookingId && !!cancellable,
enabled: !!bookingId,
});
const hasOpenCancellation = (history?.items ?? []).some(
(r) => r.bookingId === bookingId && r.status === "FEE_PENDING",
const ownCancellations = (history?.items ?? []).filter(
(r) => r.bookingId === bookingId,
);
const hasOpenCancellation = ownCancellations.some(
(r) => r.status === "FEE_PENDING",
);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -456,7 +560,9 @@ export function WagonsTab({
if (!wagons?.length) {
return (
<SectionCard style={{ maxWidth: 980 }}>
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<CancelledWagonsSection rows={ownCancellations} />
<SectionCard>
<Group gap={12} align="center">
<Box
style={{
@@ -483,6 +589,7 @@ export function WagonsTab({
</Box>
</Group>
</SectionCard>
</div>
);
}
@@ -569,9 +676,10 @@ export function WagonsTab({
Cancel specific wagons
</Text>
<Text fz={12.5} c="#9AA8B5">
Tick the wagons you want to cancel. A per-wagon cancellation fee
applies; the wagons stay yours until the fee is paid, and the
freight you paid for them becomes a rebooking credit.
Tick the wagons you want to cancel. They leave this train
immediately; after you pay the per-wagon cancellation fee, the
freight you paid for them becomes a credit you can rebook on
another day.
</Text>
</Box>
<Button
@@ -597,6 +705,8 @@ export function WagonsTab({
</Alert>
)}
<CancelledWagonsSection rows={ownCancellations} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => (
<WagonCard

View File

@@ -242,7 +242,21 @@ export interface WagonCancellation {
wagonsCancelled: number;
weightTons: number;
/** What was cut: bulk tons, or container units per size (ft). */
cancelledQuantities: { bulkTons?: number; bySize?: Record<string, number> };
cancelledQuantities: {
bulkTons?: number;
bySize?: Record<string, number>;
/** Exact physical containers leaving with the cancelled wagons. */
units?: Array<{
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
}>;
/** Wagons already left the schedule when the request was made. */
releasedAtRequest?: boolean;
};
/** Rebooking credit — the cancelled share of the original freight price. */
creditAmount: number;
feeAmount: number;