mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1415 from Tria-plc/freight_feature/usermanagement
feat: implement MANUAL_ONLY status for bookings and update related logic
This commit is contained in:
@@ -241,6 +241,45 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
).not.toHaveBeenCalled();
|
).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ensurePaidBookingAllocated never re-places a MANUAL_ONLY booking (removed from a train by staff)', async () => {
|
||||||
|
dataSource.getRepository().findOne.mockResolvedValue({
|
||||||
|
...paidBooking,
|
||||||
|
trainScheduleId: null,
|
||||||
|
schedulingStatus: 'MANUAL_ONLY',
|
||||||
|
} as unknown as Booking);
|
||||||
|
|
||||||
|
await service.ensurePaidBookingAllocated(bookingId);
|
||||||
|
|
||||||
|
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||||
|
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
|
||||||
|
expect(dataSource.getRepository().update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensurePaidBookingAllocated skips a MANUAL_ONLY booking even when still pinned to a schedule', async () => {
|
||||||
|
dataSource.getRepository().findOne.mockResolvedValue({
|
||||||
|
...paidBooking,
|
||||||
|
schedulingStatus: 'MANUAL_ONLY',
|
||||||
|
} as unknown as Booking);
|
||||||
|
|
||||||
|
await service.ensurePaidBookingAllocated(bookingId);
|
||||||
|
|
||||||
|
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||||
|
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconcilePaidUnlinked leaves MANUAL_ONLY bookings alone', async () => {
|
||||||
|
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
|
||||||
|
{ ...paidBooking, schedulingStatus: 'MANUAL_ONLY' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.reconcilePaidUnlinked(scheduleId);
|
||||||
|
|
||||||
|
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
trainSchedulingService.previewPaidBookingWagonShortage,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
|
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
|
||||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -577,6 +577,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// train 30s after being cancelled. Never resurrect a dead booking.
|
// train 30s after being cancelled. Never resurrect a dead booking.
|
||||||
if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status))
|
if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status))
|
||||||
return;
|
return;
|
||||||
|
// Staff removed this booking from a train (dispatch left-behind / manual
|
||||||
|
// unassign) — every auto-allocation rescue below must leave it alone, or
|
||||||
|
// the next document review / sweep silently retakes the space it was
|
||||||
|
// pulled from. Only a manual staff assignment may re-place it.
|
||||||
|
if (booking.schedulingStatus === "MANUAL_ONLY") return;
|
||||||
if (!booking.trainScheduleId) {
|
if (!booking.trainScheduleId) {
|
||||||
// A paid booking with no train is money taken and nothing boarding. The
|
// A paid booking with no train is money taken and nothing boarding. The
|
||||||
// hold was expired before the payment landed (webhook lag beat the
|
// hold was expired before the payment landed (webhook lag beat the
|
||||||
@@ -1553,6 +1558,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
for (const booking of unlinked) {
|
for (const booking of unlinked) {
|
||||||
// Held on purpose (paid, no wagon free) — the cron must not undo it.
|
// Held on purpose (paid, no wagon free) — the cron must not undo it.
|
||||||
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
|
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
|
||||||
|
// Removed from a train by staff — manual re-assignment only.
|
||||||
|
if (booking.schedulingStatus === "MANUAL_ONLY") continue;
|
||||||
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
|
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
|
||||||
await this.allocate(scheduleId, booking, "paid");
|
await this.allocate(scheduleId, booking, "paid");
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
wagonAllocationContainerItemsRepository as never,
|
wagonAllocationContainerItemsRepository as never,
|
||||||
wagonAllocationBulkLoadsRepository as never,
|
wagonAllocationBulkLoadsRepository as never,
|
||||||
trainCheckpointEventsRepository as never,
|
trainCheckpointEventsRepository as never,
|
||||||
{} as never, // trainCompositionRemovalLogRepository
|
{ create: jest.fn() } as never, // trainCompositionRemovalLogRepository
|
||||||
{
|
{
|
||||||
autoUnloadArrivedBookings: jest.fn(),
|
autoUnloadArrivedBookings: jest.fn(),
|
||||||
autoUnloadExportAtDjibouti: jest.fn(),
|
autoUnloadExportAtDjibouti: jest.fn(),
|
||||||
@@ -182,7 +182,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
{
|
{
|
||||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||||
} as never, // bookingJourneyService
|
} as never, // bookingJourneyService
|
||||||
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
{ dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier
|
||||||
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
|
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1640,6 +1640,85 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('unassignBooking — MANUAL_ONLY status', () => {
|
||||||
|
const scheduleId = 'sched-rm-1';
|
||||||
|
const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
|
||||||
|
status: 'PAID',
|
||||||
|
wagonsRequired: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = {
|
||||||
|
id: scheduleId,
|
||||||
|
status: 'DRAFT',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
trainSetId: 'ts-rm',
|
||||||
|
trainSet: {
|
||||||
|
id: 'ts-rm',
|
||||||
|
locomotive,
|
||||||
|
trainId: null,
|
||||||
|
wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }],
|
||||||
|
},
|
||||||
|
scheduleBookings: [{ bookingId: 'bk-rm' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const txManager = {
|
||||||
|
getRepository: jest.fn(() => ({
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
delete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
create: jest.fn((x: unknown) => x),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph);
|
||||||
|
bookingsRepository.findById = jest.fn().mockResolvedValue(removed);
|
||||||
|
bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined);
|
||||||
|
dataSource.transaction.mockImplementation(
|
||||||
|
async (fn: (m: unknown) => Promise<void>) => fn(txManager),
|
||||||
|
);
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
|
||||||
|
'getTrainScheduleById' as never,
|
||||||
|
)
|
||||||
|
.mockResolvedValue({ id: scheduleId } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => {
|
||||||
|
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
|
||||||
|
|
||||||
|
expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith(
|
||||||
|
'bk-rm',
|
||||||
|
expect.objectContaining({
|
||||||
|
schedulingStatus: 'MANUAL_ONLY',
|
||||||
|
trainScheduleId: null,
|
||||||
|
wagonsRequired: null,
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith(
|
||||||
|
scheduleId,
|
||||||
|
'bk-rm',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith(
|
||||||
|
['alloc-rm-1'],
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => {
|
||||||
|
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
|
||||||
|
|
||||||
|
const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]);
|
||||||
|
expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('updateCheckpoint — leg time correction', () => {
|
describe('updateCheckpoint — leg time correction', () => {
|
||||||
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
|
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
|
||||||
const schedule = {
|
const schedule = {
|
||||||
|
|||||||
@@ -2415,7 +2415,12 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const booking = await this.bookingsRepository.findById(bookingId);
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
const schedulingStatus = this.resolvePostUnassignStatus(booking);
|
// Removed from a train by staff → MANUAL_ONLY: the paid booking must not
|
||||||
|
// be auto re-placed by any allocation sweep (it would retake the space it
|
||||||
|
// was just pulled from). Staff re-assign it manually; assign resets the
|
||||||
|
// status to SCHEDULED. Schedule *cancellation* keeps the old behaviour
|
||||||
|
// (resolvePostUnassignStatus) — there the train died, not the booking.
|
||||||
|
const schedulingStatus = SchedulingStatus.ManualOnly;
|
||||||
// Clear the schedule pointer too: unassign fully detaches the booking from
|
// Clear the schedule pointer too: unassign fully detaches the booking from
|
||||||
// this train. Leaving trainScheduleId set glued the booking to a schedule
|
// this train. Leaving trainScheduleId set glued the booking to a schedule
|
||||||
// that may then be dispatched/cancelled/deleted, orphaning it — the
|
// that may then be dispatched/cancelled/deleted, orphaning it — the
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export function SchedulingStatusBadge({ status }: { status?: string | null }) {
|
|||||||
ELIGIBLE: "blue",
|
ELIGIBLE: "blue",
|
||||||
SCHEDULED: "indigo",
|
SCHEDULED: "indigo",
|
||||||
DISPATCHED: "edr-green",
|
DISPATCHED: "edr-green",
|
||||||
|
WAITING_FOR_WAGON: "yellow",
|
||||||
|
MANUAL_ONLY: "orange",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Badge variant="light" color={colors[status] ?? "gray"} size="sm">
|
<Badge variant="light" color={colors[status] ?? "gray"} size="sm">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
|
Radio,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
@@ -23,10 +24,13 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
|
Flag,
|
||||||
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
@@ -109,6 +113,39 @@ function directionOf(template: ContractTemplate): string {
|
|||||||
: template.code.split("_")[0];
|
: template.code.split("_")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The three customs options offered in the create-template modal. */
|
||||||
|
const CUSTOMS_OPTIONS: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "true",
|
||||||
|
label: "With customs clearing",
|
||||||
|
description:
|
||||||
|
"The Service Provider clears customs in both Djibouti and Ethiopia on the client's behalf.",
|
||||||
|
color: "teal",
|
||||||
|
icon: Globe,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ethiopian",
|
||||||
|
label: "Ethiopian customs only",
|
||||||
|
description:
|
||||||
|
"The Service Provider clears the Ethiopian side only — Djibouti clearing stays with the client. Used for service types marked “Ethiopian customs only”.",
|
||||||
|
color: "indigo",
|
||||||
|
icon: Flag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "false",
|
||||||
|
label: "Without customs clearing",
|
||||||
|
description: "Transport only — the client handles its own declarations.",
|
||||||
|
color: "gray",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function formatUpdated(value: string): string {
|
function formatUpdated(value: string): string {
|
||||||
return new Date(value).toLocaleDateString("en-GB", {
|
return new Date(value).toLocaleDateString("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -281,8 +318,25 @@ function CreateTemplateModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={close} title="New bulk contract template" centered>
|
<Modal
|
||||||
<Stack gap="md">
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
size={640}
|
||||||
|
radius="lg"
|
||||||
|
padding="xl"
|
||||||
|
centered
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
New bulk contract template
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
One template per trade direction, customs option and commodity
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="lg">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500} mb={6}>
|
<Text size="sm" fw={500} mb={6}>
|
||||||
Trade direction
|
Trade direction
|
||||||
@@ -299,36 +353,6 @@ function CreateTemplateModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{intercity ? (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
Intercity contracts are domestic and cross no border, so they have
|
|
||||||
no customs clearing variant — one template per cargo type.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500} mb={6}>
|
|
||||||
Customs clearing
|
|
||||||
</Text>
|
|
||||||
<SegmentedControl
|
|
||||||
fullWidth
|
|
||||||
value={withCustoms}
|
|
||||||
onChange={setWithCustoms}
|
|
||||||
data={[
|
|
||||||
{ value: "true", label: "With customs clearing" },
|
|
||||||
{ value: "ethiopian", label: "Ethiopian customs only" },
|
|
||||||
{ value: "false", label: "Without customs clearing" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{withCustoms === "ethiopian" && (
|
|
||||||
<Text size="xs" c="dimmed" mt={6}>
|
|
||||||
Used for service types marked “Ethiopian customs only”: the
|
|
||||||
Service Provider clears the Ethiopian side, Djibouti clearing
|
|
||||||
stays with the client.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Bulk cargo type"
|
label="Bulk cargo type"
|
||||||
description="Only cargo types with “has contract template” enabled are listed"
|
description="Only cargo types with “has contract template” enabled are listed"
|
||||||
@@ -340,6 +364,62 @@ function CreateTemplateModal({
|
|||||||
nothingFoundMessage="No cargo type allows contract templates yet — enable the flag on the cargo type first"
|
nothingFoundMessage="No cargo type allows contract templates yet — enable the flag on the cargo type first"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{intercity ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Intercity contracts are domestic and cross no border, so they have
|
||||||
|
no customs clearing variant — one template per cargo type.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Radio.Group
|
||||||
|
value={withCustoms}
|
||||||
|
onChange={setWithCustoms}
|
||||||
|
label="Customs clearing"
|
||||||
|
styles={{ label: { fontWeight: 500, marginBottom: 6 } }}
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{CUSTOMS_OPTIONS.map((option) => {
|
||||||
|
const checked = withCustoms === option.value;
|
||||||
|
return (
|
||||||
|
<Radio.Card
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
radius="md"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderColor: checked
|
||||||
|
? `var(--mantine-color-${option.color}-6)`
|
||||||
|
: undefined,
|
||||||
|
backgroundColor: checked
|
||||||
|
? `var(--mantine-color-${option.color}-light)`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||||
|
<Radio.Indicator mt={2} color={option.color} />
|
||||||
|
<ThemeIcon
|
||||||
|
size={34}
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color={option.color}
|
||||||
|
>
|
||||||
|
<option.icon size={17} strokeWidth={1.75} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.45}>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Radio.Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Radio.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={close}>
|
<Button variant="default" onClick={close}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export type SchedulingStatus =
|
|||||||
| "ELIGIBLE"
|
| "ELIGIBLE"
|
||||||
| "SCHEDULED"
|
| "SCHEDULED"
|
||||||
| "DISPATCHED"
|
| "DISPATCHED"
|
||||||
| "WAITING_FOR_WAGON";
|
| "WAITING_FOR_WAGON"
|
||||||
|
/** Removed from a train by staff — auto-allocation skips it; manual assign only. */
|
||||||
|
| "MANUAL_ONLY";
|
||||||
|
|
||||||
export type TrainScheduleStatus =
|
export type TrainScheduleStatus =
|
||||||
| "DRAFT"
|
| "DRAFT"
|
||||||
|
|||||||
@@ -222,6 +222,13 @@ export enum SchedulingStatus {
|
|||||||
Dispatched = "DISPATCHED",
|
Dispatched = "DISPATCHED",
|
||||||
/** Paid, but no wagon of the required type was free — held in the day pool for manual placement. */
|
/** Paid, but no wagon of the required type was free — held in the day pool for manual placement. */
|
||||||
WaitingForWagon = "WAITING_FOR_WAGON",
|
WaitingForWagon = "WAITING_FOR_WAGON",
|
||||||
|
/**
|
||||||
|
* Staff removed this paid booking from a train (unchecked at dispatch, or
|
||||||
|
* unassigned from the schedule). Auto-allocation sweeps must skip it — it
|
||||||
|
* would silently retake the space it was pulled from. Only a manual staff
|
||||||
|
* assignment puts it back on a train (which resets it to SCHEDULED).
|
||||||
|
*/
|
||||||
|
ManualOnly = "MANUAL_ONLY",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TrainScheduleStatus {
|
export enum TrainScheduleStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user