mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 01:20:55 +00:00
feat: add pagination to schedule history and consolidation approvals
- Implemented pagination in ScheduleHistoryPanel to manage large history entries. - Updated API to support pagination parameters for schedule history. - Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows. - Introduced new types for paginated responses in bookings and train scheduling services. - Added a database migration to create an index on wagon_booking_allocations for performance improvements.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import type { AuthUserPayload } from "../../../common/resolve-auth-user-id";
|
||||
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
|
||||
import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
|
||||
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
||||
|
||||
@@ -243,14 +244,27 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/phase")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Lightweight polling heartbeat: the schedule's status, booking-window phase and deadlines plus its updated_at — one row, no joins, so clients can poll cheaply and refetch the full detail only when something actually changed",
|
||||
})
|
||||
getSchedulePhase(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getSchedulePhase(id);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/history")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
|
||||
})
|
||||
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getScheduleHistory(id);
|
||||
getScheduleHistory(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query() query: PaginationQueryDto,
|
||||
) {
|
||||
return this.trainSchedulingService.getScheduleHistory(id, query);
|
||||
}
|
||||
|
||||
@Get("bookable-schedules")
|
||||
|
||||
@@ -4379,7 +4379,10 @@ export class TrainSchedulingService {
|
||||
|
||||
/** Log the train passing a station. Logging the destination station triggers arrival. */
|
||||
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
// Slim graph: checkpoint logging reads stops, locomotives, the built
|
||||
// train and the wagon plans — never the booking/container branches.
|
||||
// (arriveSchedule, invoked on the final leg, loads its own full graph.)
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
@@ -4473,9 +4476,23 @@ export class TrainSchedulingService {
|
||||
const cutNow = Object.entries(cutPlan).filter(([, yardId]) =>
|
||||
passedYardIds.includes(yardId),
|
||||
);
|
||||
// One fetch for the whole plan, one bulk insert per log table — the
|
||||
// per-wagon UPDATEs stay (each patch differs) but the transaction no
|
||||
// longer serializes a findOne + save pair per wagon.
|
||||
const cutWagonById = new Map(
|
||||
cutNow.length
|
||||
? (
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { id: In(cutNow.map(([wagonId]) => wagonId)) } })
|
||||
).map((w) => [w.id, w])
|
||||
: [],
|
||||
);
|
||||
const adjustmentRows: ScheduleWagonAdjustmentLog[] = [];
|
||||
const movementRows: WagonMovement[] = [];
|
||||
let realCutHappened = false;
|
||||
for (const [wagonId, cutYardId] of cutNow) {
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
const wagon = cutWagonById.get(wagonId);
|
||||
// Already settled earlier (or re-pinned elsewhere) — not ours to move.
|
||||
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||
if (realCutIds.has(wagonId) && builtTrainId) {
|
||||
@@ -4497,7 +4514,7 @@ export class TrainSchedulingService {
|
||||
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
|
||||
[wagonId, builtTrainId],
|
||||
);
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
adjustmentRows.push(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId,
|
||||
@@ -4519,7 +4536,7 @@ export class TrainSchedulingService {
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
});
|
||||
}
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
movementRows.push(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId,
|
||||
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId,
|
||||
@@ -4530,6 +4547,12 @@ export class TrainSchedulingService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (adjustmentRows.length) {
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(adjustmentRows);
|
||||
}
|
||||
if (movementRows.length) {
|
||||
await manager.getRepository(WagonMovement).save(movementRows);
|
||||
}
|
||||
// Keep the coupling order gapless after permanent removals.
|
||||
if (realCutHappened && builtTrainId) {
|
||||
const remaining = await manager.getRepository(Wagon).find({
|
||||
@@ -4557,8 +4580,16 @@ export class TrainSchedulingService {
|
||||
select: { id: true, sequenceNumber: true },
|
||||
});
|
||||
let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
||||
const coupleWagonById = new Map(
|
||||
(
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { id: In(coupleNow.map(([wagonId]) => wagonId)) } })
|
||||
).map((w) => [w.id, w]),
|
||||
);
|
||||
const coupleLogRows: ScheduleWagonAdjustmentLog[] = [];
|
||||
for (const [wagonId, coupleYardId] of coupleNow) {
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
const wagon = coupleWagonById.get(wagonId);
|
||||
if (
|
||||
!wagon ||
|
||||
wagon.trainId ||
|
||||
@@ -4575,7 +4606,7 @@ export class TrainSchedulingService {
|
||||
status: WagonStatus.Assigned,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
});
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
coupleLogRows.push(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId,
|
||||
@@ -4588,6 +4619,9 @@ export class TrainSchedulingService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (coupleLogRows.length) {
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows);
|
||||
}
|
||||
}
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
@@ -4798,11 +4832,23 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
// One fetch for every pinned wagon and bulk log/ledger inserts — the
|
||||
// per-wagon UPDATEs stay (patches differ per wagon).
|
||||
const pinnedIds = (schedule.trainSet?.wagons ?? [])
|
||||
.map((slot) => slot.physicalWagonId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const settleWagonById = new Map(
|
||||
pinnedIds.length
|
||||
? (
|
||||
await manager.getRepository(Wagon).find({ where: { id: In(pinnedIds) } })
|
||||
).map((w) => [w.id, w])
|
||||
: [],
|
||||
);
|
||||
const arrivalLogRows: ScheduleWagonAdjustmentLog[] = [];
|
||||
const arrivalMovementRows: WagonMovement[] = [];
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
if (!slot.physicalWagonId) continue;
|
||||
const wagon = await manager
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: slot.physicalWagonId } });
|
||||
const wagon = settleWagonById.get(slot.physicalWagonId);
|
||||
if (!wagon) continue;
|
||||
// A wagon that already alighted mid-route (unload released it, possibly
|
||||
// re-pinned elsewhere since) is no longer this schedule's to move.
|
||||
@@ -4839,7 +4885,7 @@ export class TrainSchedulingService {
|
||||
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
|
||||
[wagon.id, ownerTrainId],
|
||||
);
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
arrivalLogRows.push(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: ownerTrainId,
|
||||
@@ -4863,7 +4909,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
// Ledger: the wagon rode this schedule to its settle yard.
|
||||
const slotAllocations = slot.allocations ?? [];
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
arrivalMovementRows.push(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: slot.boardYardId ?? schedule.originStationId,
|
||||
@@ -4884,10 +4930,22 @@ export class TrainSchedulingService {
|
||||
// so a still-loose planned couple physically rode along.
|
||||
const arrivalCouplePlan = schedule.plannedWagonCouples ?? {};
|
||||
const arrivalTrainId = schedule.trainSet?.trainId ?? null;
|
||||
for (const [coupleWagonId, coupleYardId] of Object.entries(arrivalCouplePlan)) {
|
||||
const wagon = await manager
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: coupleWagonId } });
|
||||
const coupleEntries = Object.entries(arrivalCouplePlan);
|
||||
const arrivalCoupleById = new Map(
|
||||
coupleEntries.length
|
||||
? (
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { id: In(coupleEntries.map(([wagonId]) => wagonId)) } })
|
||||
).map((w) => [w.id, w])
|
||||
: [],
|
||||
);
|
||||
// Join sequence numbers continue after the settled consist; the max is
|
||||
// read once and incremented locally — identical to re-querying after
|
||||
// each join, without one consist scan per wagon.
|
||||
let arrivalMaxSeq: number | null = null;
|
||||
for (const [coupleWagonId, coupleYardId] of coupleEntries) {
|
||||
const wagon = arrivalCoupleById.get(coupleWagonId);
|
||||
if (!wagon) continue;
|
||||
if (wagon.currentTrainScheduleId === scheduleId) {
|
||||
// Joined during the trip, slot-less: settle at the destination.
|
||||
@@ -4897,7 +4955,7 @@ export class TrainSchedulingService {
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
arrivalMovementRows.push(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: coupleYardId,
|
||||
@@ -4914,18 +4972,21 @@ export class TrainSchedulingService {
|
||||
!wagon.currentTrainScheduleId &&
|
||||
wagon.currentYardId === coupleYardId
|
||||
) {
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: arrivalTrainId },
|
||||
select: { id: true, sequenceNumber: true },
|
||||
});
|
||||
const maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
||||
if (arrivalMaxSeq === null) {
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: arrivalTrainId },
|
||||
select: { id: true, sequenceNumber: true },
|
||||
});
|
||||
arrivalMaxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
||||
}
|
||||
arrivalMaxSeq += 1;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: arrivalTrainId,
|
||||
sequenceNumber: maxSeq + 1,
|
||||
sequenceNumber: arrivalMaxSeq,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
arrivalLogRows.push(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: arrivalTrainId,
|
||||
@@ -4937,7 +4998,7 @@ export class TrainSchedulingService {
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
arrivalMovementRows.push(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: coupleYardId,
|
||||
@@ -4949,6 +5010,12 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (arrivalLogRows.length) {
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows);
|
||||
}
|
||||
if (arrivalMovementRows.length) {
|
||||
await manager.getRepository(WagonMovement).save(arrivalMovementRows);
|
||||
}
|
||||
|
||||
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||||
const stations = await this.buildScheduleStations(schedule);
|
||||
@@ -5720,6 +5787,39 @@ export class TrainSchedulingService {
|
||||
return rows[0]?.train_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling heartbeat for the detail page: one row, no joins. Clients compare
|
||||
* this snapshot between polls and refetch the (expensive) full detail only
|
||||
* when it changed — `updatedAt` catches any schedule-row write, the phase
|
||||
* fields drive countdowns directly.
|
||||
*/
|
||||
async getSchedulePhase(scheduleId: string) {
|
||||
const rows: Array<{
|
||||
status: string;
|
||||
bookingWindowStatus: string | null;
|
||||
windowPhase: string | null;
|
||||
windowOpensAt: Date | null;
|
||||
windowClosesAt: Date | null;
|
||||
docReviewEndsAt: Date | null;
|
||||
paymentPhaseEndsAt: Date | null;
|
||||
updatedAt: Date;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT status,
|
||||
booking_window_status AS "bookingWindowStatus",
|
||||
window_phase AS "windowPhase",
|
||||
window_opens_at AS "windowOpensAt",
|
||||
window_closes_at AS "windowClosesAt",
|
||||
doc_review_ends_at AS "docReviewEndsAt",
|
||||
payment_phase_ends_at AS "paymentPhaseEndsAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM freight.train_schedules
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (!rows[0]) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */
|
||||
private async plannedWagonYardsOf(
|
||||
scheduleId: string | undefined,
|
||||
@@ -5760,17 +5860,35 @@ export class TrainSchedulingService {
|
||||
return rows[0]?.planned_wagon_couples ?? {};
|
||||
}
|
||||
|
||||
/** Wagon types are near-static reference data — 60s TTL like the batch service's dims cache. */
|
||||
private wagonTypesCache: { value: WagonType[]; expiresAt: number } | null = null;
|
||||
|
||||
private async loadWagonTypesCached(): Promise<WagonType[]> {
|
||||
if (this.wagonTypesCache && this.wagonTypesCache.expiresAt > Date.now()) {
|
||||
return this.wagonTypesCache.value;
|
||||
}
|
||||
const value = await this.dataSource.getRepository(WagonType).find();
|
||||
this.wagonTypesCache = { value, expiresAt: Date.now() + 60_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
private async countFleetAvailability(
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||||
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find(),
|
||||
this.dataSource.getRepository(WagonType).find(),
|
||||
const [wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
|
||||
this.loadWagonTypesCached(),
|
||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||
this.plannedWagonYardsOf(targetScheduleId),
|
||||
]);
|
||||
// Only two wagon populations can ever count below: the built train's own
|
||||
// consist, or (train-less schedules) loose wagons — `if (wagon.trainId)
|
||||
// continue` used to drop everything else in JS after loading the whole
|
||||
// national fleet. Same result, fleet-sized query avoided.
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: builtTrainId ? { trainId: builtTrainId } : { trainId: IsNull() },
|
||||
});
|
||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||
const counts = new Map<string, { code: string; available: number }>();
|
||||
|
||||
@@ -5945,9 +6063,16 @@ export class TrainSchedulingService {
|
||||
slots: TrainSetWagon[],
|
||||
reverseWagonOrder = false,
|
||||
) {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
|
||||
// pickPhysicalWagonForSlot can only ever pin the built train's own wagons,
|
||||
// couple-planned loose wagons, or (loose-pool schedules) wagons with no
|
||||
// train — its own filters reject everything else, so don't load the fleet.
|
||||
const wagons = await manager.getRepository(Wagon).find({
|
||||
where: builtTrainId
|
||||
? [{ trainId: builtTrainId }, { trainId: IsNull() }]
|
||||
: { trainId: IsNull() },
|
||||
});
|
||||
const wagonTypes = await this.loadWagonTypesCached();
|
||||
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
|
||||
scheduleId,
|
||||
manager,
|
||||
@@ -6034,11 +6159,17 @@ export class TrainSchedulingService {
|
||||
): Promise<string[]> {
|
||||
if (!wagonPlan.length) return [];
|
||||
|
||||
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find(),
|
||||
const [builtTrainId, pinnedToScheduleIds] = await Promise.all([
|
||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||
]);
|
||||
// Same population argument as autoPinWagonsForSchedule: consist + loose
|
||||
// wagons are the only candidates the pin filters can accept.
|
||||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||
where: builtTrainId
|
||||
? [{ trainId: builtTrainId }, { trainId: IsNull() }]
|
||||
: { trainId: IsNull() },
|
||||
});
|
||||
const targetSchedule = targetScheduleId
|
||||
? await this.trainSchedulesRepository.findById(targetScheduleId)
|
||||
: null;
|
||||
@@ -6906,7 +7037,9 @@ export class TrainSchedulingService {
|
||||
* (already carrying this schedule's cargo).
|
||||
*/
|
||||
async getScheduleWagonYards(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
// Slim graph: this read needs stops, the built train, and which slots
|
||||
// carry allocations — not the full booking/container branches.
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId);
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
const builtTrain = schedule.trainSet?.train;
|
||||
if (!builtTrain) {
|
||||
@@ -8227,7 +8360,7 @@ export class TrainSchedulingService {
|
||||
* through iam.users; rows survive wagon/train deletion (log tables carry
|
||||
* plain columns, no FKs).
|
||||
*/
|
||||
async getScheduleHistory(scheduleId: string) {
|
||||
async getScheduleHistory(scheduleId: string, query: { page?: number; pageSize?: number } = {}) {
|
||||
type HistoryRow = {
|
||||
id: string;
|
||||
kind: 'WAGON' | 'BOOKING';
|
||||
@@ -8238,105 +8371,83 @@ export class TrainSchedulingService {
|
||||
note: string | null;
|
||||
occurredAt: Date;
|
||||
};
|
||||
const wagonRows: HistoryRow[] = (
|
||||
await this.dataSource.query(
|
||||
`SELECT l.id,
|
||||
l.action,
|
||||
l.wagon_number AS "subject",
|
||||
COALESCE(y.label, y.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
l.occurred_at AS "occurredAt"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
||||
WHERE l.train_schedule_id = $1
|
||||
AND l.deleted_at IS NULL
|
||||
ORDER BY l.occurred_at DESC
|
||||
LIMIT 200`,
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
// One UNION ALL over the four event sources, paginated in SQL — the old
|
||||
// shape capped each source at 200 and merge-sorted up to 800 rows in
|
||||
// memory per request. Same rows, same order, same field mapping.
|
||||
const historyCte = `
|
||||
SELECT l.id::text AS "id",
|
||||
'WAGON' AS "kind",
|
||||
l.action AS "action",
|
||||
l.wagon_number AS "subject",
|
||||
COALESCE(y.label, y.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
NULL::text AS "note",
|
||||
l.occurred_at AS "occurredAt"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
||||
WHERE l.train_schedule_id = $1
|
||||
AND l.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT r.id::text,
|
||||
'BOOKING',
|
||||
'BOOKING_REMOVED',
|
||||
r.booking_reference,
|
||||
NULL,
|
||||
COALESCE(u.username, u.email),
|
||||
r.notes,
|
||||
r.removed_at
|
||||
FROM freight.train_composition_removal_logs r
|
||||
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
|
||||
WHERE r.schedule_id = $1
|
||||
AND r.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT b.id::text,
|
||||
'BOOKING',
|
||||
'BOOKING_LOADED',
|
||||
b.reference,
|
||||
COALESCE(oy.label, oy.code),
|
||||
COALESCE(u.username, u.email),
|
||||
NULL,
|
||||
b.loaded_at
|
||||
FROM freight.bookings b
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id
|
||||
WHERE b.loaded_at IS NOT NULL
|
||||
AND b.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT b.id::text,
|
||||
'BOOKING',
|
||||
'BOOKING_UNLOADED',
|
||||
b.reference,
|
||||
COALESCE(dy.label, dy.code),
|
||||
COALESCE(u.username, u.email),
|
||||
NULL,
|
||||
b.arrived_at
|
||||
FROM freight.bookings b
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id
|
||||
WHERE b.arrived_at IS NOT NULL
|
||||
AND b.deleted_at IS NULL`;
|
||||
const [countRows, rows]: [Array<{ total: string }>, HistoryRow[]] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT count(*) AS total FROM (${historyCte}) history`,
|
||||
[scheduleId],
|
||||
)
|
||||
).map((r: Omit<HistoryRow, 'kind' | 'note'>) => ({
|
||||
...r,
|
||||
kind: 'WAGON' as const,
|
||||
note: null,
|
||||
}));
|
||||
const bookingRows: HistoryRow[] = (
|
||||
await this.dataSource.query(
|
||||
`SELECT r.id,
|
||||
r.booking_reference AS "subject",
|
||||
r.notes AS "note",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
r.removed_at AS "occurredAt"
|
||||
FROM freight.train_composition_removal_logs r
|
||||
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
|
||||
WHERE r.schedule_id = $1
|
||||
AND r.deleted_at IS NULL
|
||||
ORDER BY r.removed_at DESC
|
||||
LIMIT 200`,
|
||||
[scheduleId],
|
||||
)
|
||||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'yardLabel'>) => ({
|
||||
...r,
|
||||
kind: 'BOOKING' as const,
|
||||
action: 'BOOKING_REMOVED',
|
||||
yardLabel: null,
|
||||
}));
|
||||
// Per-booking journey events (load at boarding yard / unload at alighting
|
||||
// yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a
|
||||
// multi-stop train's disjoint legs (a→b loads then unloads at b while a→c
|
||||
// rides through) each show as their own row. Append-only: these columns are
|
||||
// only ever set once per booking, never cleared, so rows never disappear.
|
||||
const journeyRows: HistoryRow[] = (
|
||||
await this.dataSource.query(
|
||||
`SELECT b.id,
|
||||
b.reference AS "subject",
|
||||
COALESCE(oy.label, oy.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
b.loaded_at AS "occurredAt"
|
||||
FROM freight.bookings b
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id
|
||||
WHERE b.loaded_at IS NOT NULL
|
||||
AND b.deleted_at IS NULL
|
||||
ORDER BY b.loaded_at DESC
|
||||
LIMIT 200`,
|
||||
[scheduleId],
|
||||
)
|
||||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'note'>) => ({
|
||||
...r,
|
||||
kind: 'BOOKING' as const,
|
||||
action: 'BOOKING_LOADED',
|
||||
note: null,
|
||||
}));
|
||||
const unloadRows: HistoryRow[] = (
|
||||
await this.dataSource.query(
|
||||
`SELECT b.id,
|
||||
b.reference AS "subject",
|
||||
COALESCE(dy.label, dy.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
b.arrived_at AS "occurredAt"
|
||||
FROM freight.bookings b
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id
|
||||
WHERE b.arrived_at IS NOT NULL
|
||||
AND b.deleted_at IS NULL
|
||||
ORDER BY b.arrived_at DESC
|
||||
LIMIT 200`,
|
||||
[scheduleId],
|
||||
)
|
||||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'note'>) => ({
|
||||
...r,
|
||||
kind: 'BOOKING' as const,
|
||||
action: 'BOOKING_UNLOADED',
|
||||
note: null,
|
||||
}));
|
||||
return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort(
|
||||
(a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||
);
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT * FROM (${historyCte}) history
|
||||
ORDER BY "occurredAt" DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[scheduleId, take, skip],
|
||||
),
|
||||
]);
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9752,12 +9863,24 @@ export class TrainSchedulingService {
|
||||
* yardId → display label for error messages that name corridor legs. One
|
||||
* query; unknown ids fall back to the raw id so a message never goes blank.
|
||||
*/
|
||||
private yardLabelsCache: { value: Map<string, string>; expiresAt: number } | null = null;
|
||||
|
||||
private async yardLabelMap(yardIds: string[]): Promise<Map<string, string>> {
|
||||
if (!yardIds.length) return new Map();
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: { id: In(yardIds) } });
|
||||
return new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
|
||||
// Yards are near-static — cache the whole label map for 60s instead of
|
||||
// one IN(...) query per detail/board render. A missing id degrades exactly
|
||||
// as before: the consumer falls back to the raw id.
|
||||
if (!this.yardLabelsCache || this.yardLabelsCache.expiresAt <= Date.now()) {
|
||||
const yards = await this.dataSource.getRepository(Yard).find();
|
||||
this.yardLabelsCache = {
|
||||
value: new Map(yards.map((y) => [y.id, y.label || y.code || y.id])),
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
const all = this.yardLabelsCache.value;
|
||||
return new Map(
|
||||
yardIds.filter((id) => all.has(id)).map((id) => [id, all.get(id) as string]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||||
|
||||
Reference in New Issue
Block a user