mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user