Files
edr-platform/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts
Marshal e2189040fa 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.
2026-08-23 04:49:58 +00:00

231 lines
7.4 KiB
TypeScript

import { Injectable } from "@nestjs/common";
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.
*/
@Injectable()
export class ConsolidationApprovalsRepository {
private readonly repository: Repository<ConsolidationApproval>;
constructor(private readonly dataSource: DataSource) {
this.repository = this.dataSource.getRepository(ConsolidationApproval);
}
/**
* The undecided request covering `bookingId`, from EITHER side of the pair —
* one row governs both halves, and the caller may hold either one.
*/
findPendingForBooking(
bookingId: string,
): Promise<ConsolidationApproval | null> {
return this.repository.findOne({
where: [
{ bookingId, status: ConsolidationApprovalStatus.Pending },
{
partnerBookingId: bookingId,
status: ConsolidationApprovalStatus.Pending,
},
],
});
}
/** Every request touching this booking, newest first (the audit trail). */
findAllForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
return this.repository.find({
where: [{ bookingId }, { partnerBookingId: bookingId }],
order: { createdAt: "DESC" },
});
}
findById(id: string): Promise<ConsolidationApproval | null> {
return this.repository.findOne({ where: { id } });
}
/**
* 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: {
bookingId: string;
partnerBookingId: string;
requestedBy?: string | null;
scheduledDate?: Date | null;
bookingReference?: string | null;
partnerBookingReference?: string | null;
}): Promise<ConsolidationApproval> {
return this.repository.save(
this.repository.create({
...input,
status: ConsolidationApprovalStatus.Pending,
requestedAt: new Date(),
}),
);
}
/**
* Record the decision. Written only against a row still PENDING, so two
* approvers racing on the same pairing cannot both succeed — the second
* update matches nothing and the caller sees `false`.
*/
async decide(
id: string,
status:
| ConsolidationApprovalStatus.Approved
| 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: In(from) },
{
status,
decidedBy,
decidedAt: new Date(),
decisionNote: decisionNote ?? null,
},
);
return (result.affected ?? 0) > 0;
}
/** Undecided requests covering any of these bookings (list badging). */
findPendingForBookings(
bookingIds: string[],
): Promise<ConsolidationApproval[]> {
if (bookingIds.length === 0) return Promise.resolve([]);
return this.repository.find({
where: [
{
bookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,
},
{
partnerBookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,
},
],
});
}
}