Merge pull request #962 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-26 11:12:23 +03:00
committed by GitHub
95 changed files with 3521 additions and 388 deletions

View File

@@ -3000,6 +3000,22 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* How many bookings on this route-day would be expired if document review
* ended right now — i.e. requests staff have neither accepted nor rejected.
* Same query the doc-review-end sweep runs, so the number staff see is
* exactly what is at risk.
*/
async countUnacceptedForRouteDay(group: RouteDayGroup): Promise<number> {
const corridorYards = await this.corridorYardsForRouteDay(group);
if (corridorYards.length === 0) return 0;
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
corridorYards,
group.day,
);
return unaccepted.length;
}
/**
* Free capacity for a government booking by displacing the lowest-priority commercial
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.

View File

@@ -22,6 +22,7 @@ describe('BookingWindowService — window state machine', () => {
expireLeftoverDayPool: jest.Mock;
expireLeftoverExportDay: jest.Mock;
fillFromWaitingList: jest.Mock;
countUnacceptedForRouteDay: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -79,6 +80,7 @@ describe('BookingWindowService — window state machine', () => {
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
fillFromWaitingList: jest.fn().mockResolvedValue(0),
countUnacceptedForRouteDay: jest.fn().mockResolvedValue(0),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
@@ -267,4 +269,87 @@ describe('BookingWindowService — window state machine', () => {
expect(s.windowPhase).toBe('OPEN');
expect(batch.setWindow).not.toHaveBeenCalled();
});
// ---- header alarm ---------------------------------------------------------
describe('getDocReviewAlert', () => {
const reviewing = (over: Partial<TrainSchedule>): TrainSchedule =>
baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
...over,
});
it('returns null when nothing is under document review', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
baseSchedule({ windowPhase: 'OPEN' }),
]);
expect(await service.getDocReviewAlert()).toBeNull();
});
it('returns null when every request on the route-day is decided', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
batch.countUnacceptedForRouteDay.mockResolvedValue(0);
expect(await service.getDocReviewAlert()).toBeNull();
});
it('reports the deadline, its own pending count and the phase length', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
batch.countUnacceptedForRouteDay.mockResolvedValue(3);
const alert = await service.getDocReviewAlert();
expect(alert).toMatchObject({
scheduleId,
originYardId: 'yard-o',
destinationYardId: 'yard-d',
tradeDirection: 'IMPORT',
pendingCount: 3,
docReviewMinutes: 30,
docReviewEndsAt: '2026-07-01T01:30:00.000Z',
});
});
it('skips the nearest deadline when it has nothing pending', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
reviewing({
id: 'sched-later',
destinationStationId: 'yard-far',
docReviewEndsAt: new Date('2026-07-01T02:00:00.000Z'),
}),
reviewing({ id: 'sched-soon' }),
]);
// Nearest (sched-soon, yard-d) is clear; the later route-day still isn't.
batch.countUnacceptedForRouteDay.mockImplementation(
async (g: { destinationYardId: string }) =>
g.destinationYardId === 'yard-far' ? 2 : 0,
);
const alert = await service.getDocReviewAlert();
expect(alert?.scheduleId).toBe('sched-later');
expect(alert?.pendingCount).toBe(2);
});
it('counts a route-day once when sibling trains share the review phase', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
reviewing({ id: 'sched-a' }),
reviewing({ id: 'sched-b' }),
]);
batch.countUnacceptedForRouteDay.mockResolvedValue(4);
const alert = await service.getDocReviewAlert();
expect(alert?.pendingCount).toBe(4);
expect(batch.countUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
});
it('ignores a phase staff already completed early', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
reviewing({ docReviewCompletedAt: new Date('2026-07-01T01:10:00.000Z') }),
]);
batch.countUnacceptedForRouteDay.mockResolvedValue(5);
expect(await service.getDocReviewAlert()).toBeNull();
});
});
});

View File

@@ -30,6 +30,28 @@ import {
} from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
* The most urgent document-review deadline that still has un-accepted booking
* requests behind it. Backoffice counts down to it and warns staff, because
* everything still pending when the phase ends is expired automatically.
*/
export interface DocReviewAlert {
/** A schedule of the route-day group under review (deep-link target). */
scheduleId: string;
originYardId: string;
destinationYardId: string;
/** EAT booking day of the group, YYYY-MM-DD. */
day: string;
/** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */
tradeDirection: string;
/** ISO deadline the review phase ends at. */
docReviewEndsAt: string;
/** Full length of the review phase — the client warns past its halfway mark. */
docReviewMinutes: number;
/** Requests neither accepted nor rejected — they expire at the deadline. */
pendingCount: number;
}
/**
* Drives the one-booking-day window cycle for IMPORT schedules and the FCFS
* booking window for EXPORT schedules. All state lives in DB timestamps on the
@@ -130,6 +152,65 @@ export class BookingWindowService implements OnModuleInit {
}
}
/**
* The route-day currently in document review whose deadline is nearest and
* which still has un-accepted requests. Null when nothing is under review or
* every request has been decided — the backoffice header shows nothing then.
*
* One card, one deadline, one count: route-days are checked in deadline order
* and the first with pending work wins, so the number always belongs to the
* clock beside it.
*/
async getDocReviewAlert(): Promise<DocReviewAlert | null> {
const reviewing = (
await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
})
)
.filter(
(s) =>
s.windowPhase === 'DOC_REVIEW' &&
s.docReviewCompletedAt == null &&
s.docReviewEndsAt != null &&
s.scheduledDepartureDate != null,
)
.sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime());
if (reviewing.length === 0) return null;
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const seen = new Set<string>();
for (const schedule of reviewing) {
const group = {
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
};
// Sibling trains share one review phase for the route-day pool — count it once.
const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`;
if (seen.has(key)) continue;
seen.add(key);
const pendingCount =
await this.bookingBatchService.countUnacceptedForRouteDay(group);
if (pendingCount === 0) continue;
return {
scheduleId: schedule.id,
...group,
// Carried so the backoffice list opens on the same direction the
// at-risk requests belong to (import corridor, or a domestic day).
tradeDirection: schedule.direction ?? 'IMPORT',
docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(),
docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes,
pendingCount,
};
}
return null;
}
/** Staff finished document review early — start the batch/payment phase now. */
async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);

View File

@@ -8,6 +8,7 @@ import {
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import {
BookingDocReviewAlert,
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingReschedule,
@@ -747,6 +748,18 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Get("doc-review-alert")
// Dedicated permission, not scheduling or bookings:view — the alarm is meant
// for the position types that actually decide operation requests.
@BookingDocReviewAlert()
@ApiOperation({
summary:
"Nearest document-review deadline that still has un-accepted booking requests behind it (null when there is none) — drives the backoffice header countdown",
})
async getDocReviewAlert() {
return this.bookingWindowService.getDocReviewAlert();
}
@Post("schedules/:id/doc-review-complete")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -6659,6 +6659,8 @@ export class TrainSchedulingService {
: slot.physicalWagonId ?? null;
if (physicalId) coveredPhysicalIds.add(physicalId);
}
// Fallback only — real empty rows below carry the wagon's OWN physical
// sequenceNumber, not an invented tail position (see emptyConsistWagons).
const maxSlotSequenceNo = Math.max(
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
@@ -6669,7 +6671,11 @@ export class TrainSchedulingService {
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
// The wagon's REAL coupling position, so an empty wagon in the middle
// of the train draws in the middle — not appended after every loaded
// slot. Falls back to a tail position only if the wagon somehow has
// no sequence number of its own.
sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
@@ -6779,8 +6785,7 @@ export class TrainSchedulingService {
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
wagons: (schedule.trainSet.wagons ?? [])
.map((wagon) => {
// Frozen schedules read the wagon number + allocations from the
// snapshot slot; the immutable slot geometry (capacity/type) still
@@ -6788,9 +6793,20 @@ export class TrainSchedulingService {
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(wagon.id)
: undefined;
// Draw the slot at its physical wagon's REAL coupling position,
// not the planning-time slot index — the two diverge once a
// load has been dragged onto a different wagon (moveWagonLoad
// repoints physicalWagonId but a slot keeps its own sequenceNo),
// or once wagon types were interleaved at pinning time. Frozen
// and not-yet-pinned slots have no live physical wagon to trust,
// so they keep their own slot sequence.
const sequenceNo =
frozenSlot || !wagon.physicalWagon
? wagon.sequenceNo
: (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo);
return {
id: wagon.id,
sequenceNo: wagon.sequenceNo,
sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
@@ -6859,7 +6875,12 @@ export class TrainSchedulingService {
})) ?? [],
};
})
.concat(emptyConsistWagons),
.concat(emptyConsistWagons)
.sort((a, b) =>
schedule.reverseWagonOrder
? b.sequenceNo - a.sequenceNo
: a.sequenceNo - b.sequenceNo,
),
}
: null,
bookings: