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:
Marshal
2026-08-23 04:49:58 +00:00
parent 8e6fc09aac
commit e2189040fa
15 changed files with 1746 additions and 613 deletions

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Every slot → allocations lookup (allocator, journey load/unload, settle,
* per-leg weight guard) filters wagon_booking_allocations by
* train_set_wagon_id, which had no index — only booking_id and the pkey.
* Sequential scans grow with every allocation ever written.
*/
export class WagonAllocationSlotIndex3680000000000 implements MigrationInterface {
name = 'WagonAllocationSlotIndex3680000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_slot
ON freight.wagon_booking_allocations (train_set_wagon_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_wagon_booking_allocations_slot
`);
}
}

View File

@@ -1,9 +1,9 @@
import {
ConsolidationApprovalService,
CONSOLIDATION_APPROVAL_PENDING,
} from './consolidation-approval.service';
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
import { Booking } from './entities/booking.entity';
} from "./consolidation-approval.service";
import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity";
import { Booking } from "./entities/booking.entity";
/**
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
@@ -14,37 +14,53 @@ import { Booking } from './entities/booking.entity';
* decision on one side of a shared wagon is meaningless without the other), and
* a decided pairing cannot be decided twice.
*/
describe('ConsolidationApprovalService', () => {
describe("ConsolidationApprovalService", () => {
const PENDING = {
id: 'ap-1',
bookingId: 'b-1',
partnerBookingId: 'b-2',
id: "ap-1",
bookingId: "b-1",
partnerBookingId: "b-2",
status: ConsolidationApprovalStatus.Pending,
requestedBy: 'gl-user',
requestedBy: "gl-user",
};
function makeService(overrides: {
approvals?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
} = {}) {
function makeService(
overrides: {
approvals?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
bookingsService?: Partial<Record<string, jest.Mock>>;
/** Yard ids the caller is scoped to; null = unrestricted. */
yardScope?: string[] | null;
} = {},
) {
const approvals = {
findPendingForBooking: jest.fn().mockResolvedValue(null),
findById: jest.fn().mockResolvedValue(PENDING),
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
create: jest.fn().mockResolvedValue({ id: "ap-1" }),
decide: jest.fn().mockResolvedValue(true),
findQueue: jest.fn().mockResolvedValue([]),
findQueuePage: jest.fn().mockResolvedValue({ items: [], total: 0 }),
countByStatus: jest
.fn()
.mockResolvedValue({ PENDING: 2, APPROVED: 4, REJECTED: 1 }),
findAllForBooking: jest.fn().mockResolvedValue([]),
...overrides.approvals,
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue(undefined),
createReviewNote: jest.fn().mockResolvedValue(undefined),
resolveStaffNames: jest.fn().mockResolvedValue(new Map()),
...overrides.bookingsRepository,
};
const bookingsService = {
findById: jest.fn(async (id: string) =>
({ id, reference: `BK-${id}` }) as Booking,
findById: jest.fn(
async (id: string) =>
({
id,
reference: `BK-${id}`,
originYardId: "mojo",
destinationYardId: "djibouti",
}) as Booking,
),
...overrides.bookingsService,
};
const notifier = {
consolidationApprovalRequestedToStaff: jest.fn(),
@@ -55,6 +71,11 @@ describe('ConsolidationApprovalService', () => {
const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
};
const yardScope = {
getScopedYardIds: jest
.fn()
.mockResolvedValue(overrides.yardScope ?? null),
};
const service = new ConsolidationApprovalService(
approvals as never,
@@ -62,27 +83,28 @@ describe('ConsolidationApprovalService', () => {
bookingsService as never,
notifier as never,
dataSource as never,
yardScope as never,
);
return { service, approvals, bookingsRepository, notifier };
return { service, approvals, bookingsRepository, notifier, yardScope };
}
it('holds BOTH halves at the gate when a pairing is created', async () => {
it("holds BOTH halves at the gate when a pairing is created", async () => {
const { service, approvals, bookingsRepository, notifier } = makeService();
await service.requestApproval('b-1', 'b-2', 'gl-user');
await service.requestApproval("b-1", "b-2", "gl-user");
expect(approvals.create).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b-1',
partnerBookingId: 'b-2',
requestedBy: 'gl-user',
bookingId: "b-1",
partnerBookingId: "b-2",
requestedBy: "gl-user",
}),
);
// Neither half may sit in the operations queue while the wagon is unreviewed.
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: CONSOLIDATION_APPROVAL_PENDING,
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: CONSOLIDATION_APPROVAL_PENDING,
});
expect(
@@ -90,94 +112,102 @@ describe('ConsolidationApprovalService', () => {
).toHaveBeenCalledTimes(1);
});
it('does not open a second review for a pairing already pending', async () => {
it("does not open a second review for a pairing already pending", async () => {
const { service, approvals } = makeService({
approvals: {
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
},
});
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
const result = await service.requestApproval("b-1", "b-2", "gl-user");
expect(result).toBe(PENDING);
expect(approvals.create).not.toHaveBeenCalled();
});
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
it("releases BOTH halves to Operations on approval, logging who decided", async () => {
const { service, approvals, bookingsRepository, notifier } = makeService();
await service.approve('ap-1', 'approver-1', 'looks fine');
await service.approve("ap-1", "approver-1", "looks fine");
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
"ap-1",
ConsolidationApprovalStatus.Approved,
'approver-1',
'looks fine',
"approver-1",
"looks fine",
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_REQUEST_PENDING',
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_REQUEST_PENDING",
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: 'OPERATION_REQUEST_PENDING',
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: "OPERATION_REQUEST_PENDING",
});
// Operations only learns about the pair now — the gate is what kept it out.
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
});
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
it("sends BOTH halves back to GL on rejection, with the reason on each", async () => {
const { service, approvals, bookingsRepository } = makeService();
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
await service.reject("ap-1", "approver-1", "partner cargo is wrong");
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
"ap-1",
ConsolidationApprovalStatus.Rejected,
'approver-1',
'partner cargo is wrong',
"approver-1",
"partner cargo is wrong",
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-1',
'partner cargo is wrong',
'CHANGES_REQUESTED',
"b-1",
"partner cargo is wrong",
"CHANGES_REQUESTED",
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-2',
'partner cargo is wrong',
'CHANGES_REQUESTED',
"b-2",
"partner cargo is wrong",
"CHANGES_REQUESTED",
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_CHANGES_REQUESTED',
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_CHANGES_REQUESTED",
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: 'OPERATION_CHANGES_REQUESTED',
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: "OPERATION_CHANGES_REQUESTED",
});
});
it('lets the requester approve their own pairing', async () => {
it("lets the requester approve their own pairing", async () => {
// No maker-checker separation: the permission alone decides who may approve,
// and the audit trail still records requester and approver separately.
const { service, approvals } = makeService();
await service.approve('ap-1', 'gl-user');
await service.approve("ap-1", "gl-user");
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
"ap-1",
ConsolidationApprovalStatus.Approved,
'gl-user',
"gl-user",
undefined,
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
);
});
it('requires a reason to reject', async () => {
it("requires a reason to reject", async () => {
const { service, approvals } = makeService();
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
await expect(service.reject("ap-1", "approver-1", " ")).rejects.toThrow(
/reason is required/i,
);
expect(approvals.decide).not.toHaveBeenCalled();
});
it('refuses a pairing that was already decided', async () => {
it("refuses a pairing that was already decided", async () => {
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
@@ -187,21 +217,203 @@ describe('ConsolidationApprovalService', () => {
},
});
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
/already approved/i,
);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('loses cleanly when another approver decides the same pairing first', async () => {
it("loses cleanly when another approver decides the same pairing first", async () => {
// decide() writes only against a still-PENDING row, so the loser of the race
// affects nothing and must not move the bookings.
const { service } = makeService({
approvals: { decide: jest.fn().mockResolvedValue(false) },
});
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
/already decided by someone else/i,
);
});
it("approves a pairing that was rejected earlier, releasing both halves", async () => {
// A rejection is not final: the reviewer may change their mind, or GL may
// argue the case. Only an already-approved pairing is closed.
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
...PENDING,
status: ConsolidationApprovalStatus.Rejected,
decidedBy: "approver-1",
}),
},
});
await service.approve("ap-1", "approver-2", "resolved with GL");
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_REQUEST_PENDING",
});
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
status: "OPERATION_REQUEST_PENDING",
});
});
it("refuses to reject a pairing that was already rejected", async () => {
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
...PENDING,
status: ConsolidationApprovalStatus.Rejected,
}),
},
});
await expect(
service.reject("ap-1", "approver-1", "still wrong"),
).rejects.toThrow(/already rejected/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it("names the requester and the decider on every queue row", async () => {
// The stored ids mean nothing to a reviewer reading the history.
const { service } = makeService({
approvals: {
findQueuePage: jest.fn().mockResolvedValue({
items: [
{
...PENDING,
status: ConsolidationApprovalStatus.Approved,
decidedBy: "approver-1",
},
],
total: 1,
}),
},
bookingsRepository: {
resolveStaffNames: jest.fn().mockResolvedValue(
new Map([
["gl-user", "Selam GL"],
["approver-1", "Abebe Approver"],
]),
),
},
});
const { items, meta, counts } = await service.queue({ pageSize: 10 });
expect(items[0].requestedByName).toBe("Selam GL");
expect(items[0].decidedByName).toBe("Abebe Approver");
// Badges count the whole queue, not the page that happened to load.
expect(counts.APPROVED).toBe(4);
expect(meta).toMatchObject({
page: 1,
pageSize: 10,
total: 1,
totalPages: 1,
});
});
it("pages the queue in SQL and reports the page meta", async () => {
// The page must be cut in the query, not sliced out of a full fetch —
// otherwise ordering only holds within whatever page loaded.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 25 });
const { service } = makeService({ approvals: { findQueuePage } });
const { meta } = await service.queue({
status: ConsolidationApprovalStatus.Rejected,
page: 2,
pageSize: 10,
});
expect(findQueuePage).toHaveBeenCalledWith({
status: ConsolidationApprovalStatus.Rejected,
page: 2,
pageSize: 10,
});
expect(meta).toMatchObject({
page: 2,
totalPages: 3,
hasNextPage: true,
hasPreviousPage: true,
});
});
it("narrows the queue and the badges to the caller's yards", async () => {
// A Mojo + Adama desk sees both yards' pairings, and nothing else. The
// badges must be narrowed too, or they promise rows the caller cannot open.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
const countByStatus = jest
.fn()
.mockResolvedValue({ PENDING: 1, APPROVED: 0, REJECTED: 0 });
const { service } = makeService({
approvals: { findQueuePage, countByStatus },
yardScope: ["mojo", "adama"],
});
await service.queue({ user: { id: "u-1" }, page: 1, pageSize: 10 });
expect(findQueuePage).toHaveBeenCalledWith(
expect.objectContaining({ yardIds: ["mojo", "adama"] }),
);
expect(countByStatus).toHaveBeenCalledWith(["mojo", "adama"]);
});
it("leaves the queue unnarrowed for an unrestricted caller", async () => {
// Super admin, `yards:view_all`, or a desk with no yard mapping at all —
// the mapping narrows access, it never grants it.
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
const { service } = makeService({
approvals: { findQueuePage },
yardScope: null,
});
await service.queue({ user: { id: "u-1" } });
expect(findQueuePage).toHaveBeenCalledWith(
expect.objectContaining({ yardIds: undefined }),
);
});
it("refuses to decide a pairing outside the caller's yards", async () => {
// Hiding the row is not enough — the id is guessable from a shared link,
// and deciding moves two other yards' bookings.
const { service, bookingsRepository } = makeService({
yardScope: ["adama"],
});
await expect(
service.approve("ap-1", "approver-1", undefined, { id: "u-1" }),
).rejects.toThrow(/outside your assigned yards/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it("allows a decision when only the PARTNER half touches the caller's yard", async () => {
// The pair is one decision, so seeing one side is seeing the pairing.
const { service, bookingsRepository } = makeService({
yardScope: ["dire-dawa"],
bookingsService: {
findById: jest.fn(async (id: string) =>
id === "b-2"
? ({
id,
reference: "BK-b-2",
originYardId: "djibouti",
destinationYardId: "dire-dawa",
} as Booking)
: ({
id,
reference: "BK-b-1",
originYardId: "mojo",
destinationYardId: "djibouti",
} as Booking),
),
},
});
await service.approve("ap-1", "approver-1", undefined, { id: "u-1" });
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
status: "OPERATION_REQUEST_PENDING",
});
});
});

View File

@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
Logger,
@@ -18,6 +19,7 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
@@ -25,6 +27,12 @@ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
/** The gate's own holding status — neither half reaches Operations from here. */
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
/** An approval row with the requester's and decider's names resolved. */
export type ConsolidationApprovalView = ConsolidationApproval & {
requestedByName: string | null;
decidedByName: string | null;
};
/**
* The shared-wagon approval gate.
*
@@ -53,6 +61,7 @@ export class ConsolidationApprovalService {
private readonly bookingsService: BookingsService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
private readonly yardScope: YardScopeService,
) {}
/**
@@ -116,8 +125,16 @@ export class ConsolidationApprovalService {
approvalId: string,
decidedBy: string,
note?: string,
user?: unknown,
): Promise<{ booking: Booking; partner: Booking }> {
const approval = await this.loadPending(approvalId);
// A pairing that was rejected can still be approved later — the reviewer
// changed their mind, or GL argued the case. Only an already-approved one
// is final, since both halves have moved on to Operations by then.
const approval = await this.loadDecidable(approvalId, [
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
]);
await this.assertInScope(approval, user);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
@@ -125,6 +142,10 @@ export class ConsolidationApprovalService {
ConsolidationApprovalStatus.Approved,
decidedBy,
note,
[
ConsolidationApprovalStatus.Pending,
ConsolidationApprovalStatus.Rejected,
],
);
// Lost the race to another approver deciding the same pairing.
if (!claimed) {
@@ -162,13 +183,17 @@ export class ConsolidationApprovalService {
approvalId: string,
decidedBy: string,
reason: string,
user?: unknown,
): Promise<{ booking: Booking; partner: Booking }> {
if (!reason?.trim()) {
throw new BadRequestException(
"A reason is required to reject a consolidation.",
);
}
const approval = await this.loadPending(approvalId);
const approval = await this.loadDecidable(approvalId, [
ConsolidationApprovalStatus.Pending,
]);
await this.assertInScope(approval, user);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
@@ -212,9 +237,88 @@ export class ConsolidationApprovalService {
return { booking, partner };
}
/** Pending pairings awaiting a decision, oldest first. */
queue(): Promise<ConsolidationApproval[]> {
return this.approvals.findQueue();
/**
* One page of the review queue, or of its history: pending pairings first,
* then the decided ones, each carrying the display name of whoever requested
* and whoever decided it — the stored ids tell a reviewer nothing.
*
* `user` narrows the whole thing to the caller's yards: a Mojo desk sees the
* pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees
* both yards' pairings. The counts behind the tabs are narrowed the same way,
* so a badge never promises rows the caller cannot open.
*/
async queue(options?: {
status?: ConsolidationApprovalStatus;
page?: number;
pageSize?: number;
/** The `/auth/me` caller. Omit only for internal, unscoped reads. */
user?: unknown;
}): Promise<{
items: ConsolidationApprovalView[];
total: number;
/** Counts per status within the caller's scope — the tab badges. */
counts: Record<ConsolidationApprovalStatus, number>;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}> {
const page = Math.max(1, options?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10));
const yardIds = await this.scopedYardIds(options?.user);
const { items: rows, total } = await this.approvals.findQueuePage({
status: options?.status,
yardIds,
page,
pageSize,
});
const counts = await this.approvals.countByStatus(yardIds);
const names = await this.bookingsRepository.resolveStaffNames(
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
);
const items = rows.map((row) => ({
...row,
requestedByName: row.requestedBy
? (names.get(row.requestedBy) ?? null)
: null,
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
}));
const totalPages = Math.ceil(total / pageSize);
return {
items,
total,
counts,
meta: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
/**
* Yard ids the caller may see, or undefined for unrestricted.
*
* Scope comes from the desk they are logged in as: `yard_positions` maps a
* position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a
* `yards:view_all` holder, and a desk with NO yard mapping all resolve to
* unrestricted — the mapping narrows access, it never grants it.
*
* Called with no user only from internal paths, which are unscoped.
*/
private async scopedYardIds(user: unknown): Promise<string[] | undefined> {
if (!user) return undefined;
const scope = await this.yardScope.getScopedYardIds(user as never);
return scope ?? undefined;
}
/** Full decision history for one booking — who decided what, and when. */
@@ -227,12 +331,46 @@ export class ConsolidationApprovalService {
return this.approvals.findPendingForBooking(bookingId);
}
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
/**
* Refuse a decision on a pairing outside the caller's yards.
*
* Hiding the row from the list is not enough on its own: the id is guessable
* from a shared link, and deciding a pairing moves two other yards' bookings.
* Same rule as the list — either half's origin or destination is enough.
*/
private async assertInScope(
approval: ConsolidationApproval,
user: unknown,
): Promise<void> {
const yardIds = await this.scopedYardIds(user);
if (!yardIds) return;
const booking = await this.bookingsService.findById(approval.bookingId);
const partner = await this.bookingsService.findById(
approval.partnerBookingId,
);
const touches = (b: Booking | null | undefined) =>
!!b &&
(yardIds.includes(b.originYardId) ||
yardIds.includes(b.destinationYardId));
if (!touches(booking) && !touches(partner)) {
throw new ForbiddenException(
"This shared wagon is outside your assigned yards.",
);
}
}
/** Load a row and refuse it unless it is in one of the decidable states. */
private async loadDecidable(
approvalId: string,
allowed: ConsolidationApprovalStatus[],
): Promise<ConsolidationApproval> {
const approval = await this.approvals.findById(approvalId);
if (!approval) {
throw new NotFoundException(`Approval ${approvalId} not found`);
}
if (approval.status !== ConsolidationApprovalStatus.Pending) {
if (!allowed.includes(approval.status)) {
throw new ConflictException(
`This consolidation was already ${approval.status.toLowerCase()}.`,
);

View File

@@ -1,11 +1,41 @@
import { Injectable } from "@nestjs/common";
import { DataSource, In, Repository } from "typeorm";
import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm";
import {
ConsolidationApproval,
ConsolidationApprovalStatus,
} from "./entities/consolidation-approval.entity";
/**
* Narrow a queue query to the caller's yards.
*
* A shared wagon is visible when EITHER half of it starts or ends at one of
* those yards — the pairing is one decision, so seeing one side is seeing the
* pairing. Yards the train merely passes through do not count: only the two
* bookings' own endpoints do.
*
* `undefined` means unrestricted and adds no predicate. An EMPTY array means
* scoped-to-nothing and must match no rows — `IN ()` is not valid SQL, so it
* gets an explicit false instead of being skipped.
*/
function applyYardScope(
qb: SelectQueryBuilder<ConsolidationApproval>,
yardIds: string[] | undefined,
): void {
if (!yardIds) return;
if (!yardIds.length) {
qb.andWhere("1 = 0");
return;
}
qb.andWhere(
`(booking.originYardId IN (:...yardIds)
OR booking.destinationYardId IN (:...yardIds)
OR partnerBooking.originYardId IN (:...yardIds)
OR partnerBooking.destinationYardId IN (:...yardIds))`,
{ yardIds },
);
}
/**
* Persistence for the shared-wagon approval gate. Rows are never deleted —
* decided rows are the audit trail of who approved which pairing and when.
@@ -48,16 +78,91 @@ export class ConsolidationApprovalsRepository {
return this.repository.findOne({ where: { id } });
}
/** Pending requests for the review queue, oldest first (FIFO). */
findQueue(): Promise<ConsolidationApproval[]> {
return this.repository.find({
where: { status: ConsolidationApprovalStatus.Pending },
relations: {
booking: { company: true },
partnerBooking: { company: true },
},
order: { requestedAt: "ASC" },
});
/**
* One page of review-queue rows, with both bookings loaded.
*
* Pending rows are work still to do, so they come oldest first (FIFO) and
* ahead of everything else. Decided rows are history, so they come
* newest-decision-first. Ordering is done in SQL, not after the fact — a page
* sorted in memory would only be sorted within itself.
*
* `yardIds` narrows to the caller's yards (see YardScopeService); pass
* undefined for an unrestricted caller. The narrowing is a WHERE, not a
* post-filter, so the page and the total both count only visible rows.
*/
async findQueuePage(options: {
status?: ConsolidationApprovalStatus;
yardIds?: string[];
page: number;
pageSize: number;
}): Promise<{ items: ConsolidationApproval[]; total: number }> {
const { status, yardIds, page, pageSize } = options;
const qb = this.repository
.createQueryBuilder("approval")
.leftJoinAndSelect("approval.booking", "booking")
.leftJoinAndSelect("booking.company", "company")
.leftJoinAndSelect("approval.partnerBooking", "partnerBooking")
.leftJoinAndSelect("partnerBooking.company", "partnerCompany");
if (status) {
qb.andWhere("approval.status = :status", { status });
} else {
qb.addOrderBy(
`CASE WHEN approval.status = '${ConsolidationApprovalStatus.Pending}' THEN 0 ELSE 1 END`,
"ASC",
);
}
applyYardScope(qb, yardIds);
// Pending has no decidedAt, decided rows all do — one pair of keys orders
// both groups correctly whichever tab asked.
const [items, total] = await qb
.addOrderBy("approval.decidedAt", "DESC", "NULLS FIRST")
.addOrderBy("approval.requestedAt", "ASC")
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
/**
* Row count per status, for the tab badges — those must show the whole
* queue, not just the page currently loaded. Narrowed by the same yard scope
* as the list, so a badge never promises rows the caller cannot open.
*/
async countByStatus(
yardIds?: string[],
): Promise<Record<ConsolidationApprovalStatus, number>> {
const qb = this.repository
.createQueryBuilder("approval")
.select("approval.status", "status")
.addSelect("COUNT(*)", "count")
.groupBy("approval.status");
// The scope predicate reads both bookings, so it needs them joined even
// though the count itself selects no columns from them.
if (yardIds) {
qb.leftJoin("approval.booking", "booking").leftJoin(
"approval.partnerBooking",
"partnerBooking",
);
}
applyYardScope(qb, yardIds);
const rows = await qb.getRawMany<{
status: ConsolidationApprovalStatus;
count: string;
}>();
const counts = {
[ConsolidationApprovalStatus.Pending]: 0,
[ConsolidationApprovalStatus.Approved]: 0,
[ConsolidationApprovalStatus.Rejected]: 0,
};
for (const row of rows) counts[row.status] = Number(row.count);
return counts;
}
create(input: {
@@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository {
| ConsolidationApprovalStatus.Rejected,
decidedBy: string | null,
decisionNote?: string | null,
/** Statuses the row may be claimed FROM. Defaults to pending-only. */
from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending],
): Promise<boolean> {
const result = await this.repository.update(
{ id, status: ConsolidationApprovalStatus.Pending },
{ id, status: In(from) },
{
status,
decidedBy,
@@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository {
if (bookingIds.length === 0) return Promise.resolve([]);
return this.repository.find({
where: [
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
{
bookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,
},
{
partnerBookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,

View File

@@ -18,6 +18,30 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
return manager ? manager.getRepository(TrainSchedule) : this.repository;
}
/**
* Slim consist view for read paths that only need the route stops, the
* built train, and slot→allocation existence (e.g. the schedule-yards tab):
* skips the booking/company/container branches of the full graph, which
* dominate its cost and go unused there.
*/
findByIdWithConsistLite(id: string): Promise<TrainSchedule | null> {
return this.repository.findOne({
where: { id },
relationLoadStrategy: 'query',
relations: {
route: { milestones: { yard: true } },
trainSet: {
train: true,
locomotive: true,
locomotives: { locomotive: true },
wagons: { allocations: true },
},
originStation: true,
destinationStation: true,
},
});
}
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
return this.repo(manager).findOne({
where: { id },

View File

@@ -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")

View File

@@ -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). */