mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 20:38:17 +00:00
- 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.
231 lines
7.4 KiB
TypeScript
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,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|