add loading status management for bookings

This commit is contained in:
Marshal
2026-07-05 19:56:21 +00:00
parent 048dd05292
commit 1433796db0
8 changed files with 222 additions and 11 deletions

View File

@@ -372,6 +372,19 @@ export class TrainSchedulingController {
return this.trainSchedulingService.updateImportLoadingStatus(id, dto);
}
@Patch("schedules/:id/loading-status")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)",
})
setBookingLoadingStatus(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateImportLoadingStatusDto,
) {
return this.trainSchedulingService.setBookingLoadingStatus(id, dto);
}
@Post("schedules/:id/pin-wagons")
@TrainSchedulingManage()
@ApiOperation({ summary: "Pin physical wagons to train set slots" })

View File

@@ -787,11 +787,49 @@ export class TrainSchedulingService {
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
// Callers that add bookings without hand-picking container slots (the
// workspace "Add from pool" button, re-adding a removed booking) send no
// containerPlacements. Auto-fill them the same way the batch engine does:
// preview the wagon plan first, then lay containers into the plan's slots.
// Without this the placement validator rejects container bookings outright
// ("Container placements are required for container bookings").
let containerPlacements = dto.containerPlacements;
if (!containerPlacements?.length) {
const preview = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
[],
false,
limits,
scheduleId,
);
const containerBookings = preview.bookings.filter(
(b) => b.freightType === 'CONTAINER',
);
if (containerBookings.length) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(units, slots);
const missing = findMissingContainerNumberIssues(units, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
}
containerPlacements = generated;
}
}
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
dto.containerPlacements,
containerPlacements,
true,
limits,
scheduleId,
@@ -887,7 +925,7 @@ export class TrainSchedulingService {
savedWagons,
wagonPlan,
bookings,
dto.containerPlacements ?? [],
containerPlacements ?? [],
);
for (const booking of bookings) {
@@ -1151,6 +1189,50 @@ export class TrainSchedulingService {
return this.getImportLoadingBookings(scheduleId);
}
/**
* Flip loaded/unloaded on the schedule↔booking link from the workspace, for any
* direction (import/export/domestic). Distinct from unassign: the booking stays
* on its wagon; this only records whether cargo is physically loaded. Allowed
* only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival
* warehouse automation owns unload, so staff can no longer hand-edit the flag.
*/
async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Loading status can only be changed before dispatch (schedule is ${schedule.status})`,
);
}
const [scheduleBookings, allocations] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
// Only bookings that are on this train AND pinned to a wagon can be loaded —
// no direction/payment filter, staff load whatever is physically on the set.
const invalid = dto.bookingIds.filter(
(id) => !scheduledIds.has(id) || !allocatedIds.has(id),
);
if (invalid.length) {
throw new BadRequestException(
`Not allocated to a wagon on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getTrainScheduleById(scheduleId);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -3843,9 +3925,11 @@ export class TrainSchedulingService {
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? [])
.map((a) => a.id);
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
(w) => w.allocations ?? [],
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
const windowCfg = await this.getWindowConfig();
@@ -4004,6 +4088,11 @@ export class TrainSchedulingService {
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the
// booking itself — staff flip it per booking in the workspace before
// dispatch. Defaults UNLOADED for links written before the column.
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
})) ?? [],
};
}

View File

@@ -23,6 +23,7 @@ import {
CheckCircle2,
Inbox,
PackageCheck,
PackageX,
Repeat,
Train,
Weight,
@@ -152,6 +153,9 @@ export function ScheduleWorkspacePanel({
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(),
);
const moveSchedule = useMutation(
api.trainScheduling.moveBookingSchedule.mutationOptions(),
);
@@ -237,6 +241,31 @@ export function ScheduleWorkspacePanel({
);
};
const toggleLoaded = (
bookingId: string,
ref: string,
next: "LOADED" | "UNLOADED",
) => {
setLoading
.mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next })
.then(() => {
toast({
title:
next === "LOADED"
? `${ref} marked loaded`
: `${ref} marked unloaded`,
});
onChanged();
})
.catch((error) =>
toast({
title: "Could not update loading status",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
@@ -268,7 +297,7 @@ export function ScheduleWorkspacePanel({
<div>
<Text fw={700}>Allocation workspace</Text>
<Text size="xs" c="dimmed">
Manually add ready-to-pay bookings, remove, or reassign them
Manually add paid, unassigned bookings, remove, or reassign them
</Text>
</div>
</Group>
@@ -351,13 +380,13 @@ export function ScheduleWorkspacePanel({
<Group align="stretch" gap="lg" grow wrap="wrap">
{/* Pool */}
<PanelColumn
title="Ready to pay"
hint="Accepted · this route & day"
title="Paid · unassigned"
hint="Paid · this route & day · not on a train"
count={pool.length}
accent="#F2A516"
loading={poolQuery.isLoading}
emptyIcon={Inbox}
emptyText="No ready-to-pay bookings waiting for this train."
emptyText="No paid, unassigned bookings waiting for this train."
>
{pool.map((b) => (
<BookingCard
@@ -402,9 +431,52 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
right={
canManage ? (
<Group gap={6} wrap="nowrap" justify="flex-end">
{b.wagonAssigned ? (
<Tooltip
label={
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "Mark cargo unloaded from wagon"
: "Mark cargo loaded onto wagon"
}
withArrow
>
<Button
size="compact-sm"
variant={
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "light"
: "filled"
}
color="edr-green"
radius="md"
leftSection={
(b.loadingStatus ?? "UNLOADED") === "LOADED" ? (
<PackageX size={13} />
) : (
<PackageCheck size={13} />
)
}
loading={setLoading.isPending}
onClick={() =>
toggleLoaded(
b.id,
b.reference ?? b.id.slice(0, 8),
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "UNLOADED"
: "LOADED",
)
}
>
{(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "Unload"
: "Load"}
</Button>
</Tooltip>
) : null}
<Tooltip label="Reassign to another train" withArrow>
<Button
size="compact-sm"
@@ -565,12 +637,14 @@ function BookingCard({
customer,
weightTons,
status,
loadingStatus,
right,
}: {
reference: string;
customer?: string | null;
weightTons?: number | null;
status?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
right?: React.ReactNode;
}) {
return (
@@ -596,6 +670,16 @@ function BookingCard({
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{loadingStatus ? (
<Badge
size="sm"
radius="sm"
variant={loadingStatus === "LOADED" ? "filled" : "light"}
color={loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{loadingStatus === "LOADED" ? "Loaded" : "Unloaded"}
</Badge>
) : null}
</Group>
<Group gap={10} align="center" wrap="nowrap">
<Text size="xs" c="dimmed" truncate>

View File

@@ -328,6 +328,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-status`,
LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/loading-status`,
IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>

View File

@@ -10,8 +10,6 @@ import {
Stack,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";

View File

@@ -546,6 +546,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
setLoadingStatus: endpoint<
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
TrainScheduleDetail
>(
"train-scheduling",
"set-loading-status",
({ id, bookingIds, loadingStatus }) =>
trainSchedulingService.setLoadingStatus(id, { bookingIds, loadingStatus }),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
pinWagons: endpoint<
{ id: string; payload: PinWagonsPayload },
TrainScheduleDetail

View File

@@ -358,6 +358,17 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
setLoadingStatus: async (
scheduleId: string,
payload: { bookingIds: string[]; loadingStatus: LoadingStatus },
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.LOADING_STATUS(scheduleId),
payload,
);
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {

View File

@@ -504,6 +504,8 @@ export interface TrainScheduleDetail {
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean;
}>;
warnings?: string[];
}