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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
|
||||
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.
|
||||
if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status))
|
||||
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) {
|
||||
// A paid booking with no train is money taken and nothing boarding. 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) {
|
||||
// Held on purpose (paid, no wagon free) — the cron must not undo it.
|
||||
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;
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
this.logger.log(
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('TrainSchedulingService', () => {
|
||||
wagonAllocationContainerItemsRepository as never,
|
||||
wagonAllocationBulkLoadsRepository as never,
|
||||
trainCheckpointEventsRepository as never,
|
||||
{} as never, // trainCompositionRemovalLogRepository
|
||||
{ create: jest.fn() } as never, // trainCompositionRemovalLogRepository
|
||||
{
|
||||
autoUnloadArrivedBookings: jest.fn(),
|
||||
autoUnloadExportAtDjibouti: jest.fn(),
|
||||
@@ -182,7 +182,7 @@ describe('TrainSchedulingService', () => {
|
||||
{
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} 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
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
|
||||
const schedule = {
|
||||
|
||||
@@ -2415,7 +2415,12 @@ export class TrainSchedulingService {
|
||||
);
|
||||
|
||||
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
|
||||
// this train. Leaving trainScheduleId set glued the booking to a schedule
|
||||
// that may then be dispatched/cancelled/deleted, orphaning it — the
|
||||
|
||||
Reference in New Issue
Block a user