mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add loading status management for bookings
This commit is contained in:
@@ -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" })
|
||||
|
||||
@@ -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),
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -504,6 +504,8 @@ export interface TrainScheduleDetail {
|
||||
status: string | null;
|
||||
schedulingStatus?: SchedulingStatus | null;
|
||||
freightType?: FreightType | string | null;
|
||||
loadingStatus?: "LOADED" | "UNLOADED";
|
||||
wagonAssigned?: boolean;
|
||||
}>;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user