mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
enhance contract and booking services with server-side search and validation improvements
- Added parameter to and for server-side free-text search on contract reference, company name, and booking details. - Introduced new validation errors in for container clashes and space issues when creating bookings. - Implemented paginated dropdown settings retrieval in . - Updated to fetch active yards using a new method that handles pagination. - Enhanced with a method to fetch all records by walking through pages. - Refactored to support filtering and pagination in schedule listings. - Improved to return a paginated list of facilities. - Updated UI components in and to utilize debounced search inputs for better performance. - Added alerts in to inform users about booking constraints related to splits and capacity. - Enhanced to display notifications for split bookings and capacity usage.
This commit is contained in:
@@ -38,8 +38,16 @@ import {
|
||||
BATCH_BOARD_STATUSES,
|
||||
BatchBoardQueryDto,
|
||||
} from './dto/batch-board-query.dto';
|
||||
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
|
||||
import {
|
||||
Freight,
|
||||
PaginatedResponse,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
} from "@edr/types";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
|
||||
|
||||
import {
|
||||
@@ -83,6 +91,21 @@ export type { Capacity } from './corridor-capacity.util';
|
||||
*/
|
||||
type TrainLimits = { base: Capacity; tolerance: OverageTolerance };
|
||||
|
||||
/**
|
||||
* Result of the export whole-booking single-train space check. `scheduleId`
|
||||
* is the earliest fillable train that carries the whole booking, or null when
|
||||
* none can — then `bestAvailable` reports the largest single-train leftover
|
||||
* in the booking's own units and `fullMessage` is the customer-facing copy.
|
||||
*/
|
||||
export interface ExportSpaceReport {
|
||||
scheduleId: string | null;
|
||||
trainsForDay: boolean;
|
||||
corridorMatched: boolean;
|
||||
need: Capacity;
|
||||
bestAvailable: { wagons: number; cargoTons: number } | null;
|
||||
fullMessage: string | null;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
@@ -241,15 +264,10 @@ export interface BatchBoardSchedule {
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
/** Paginated batch-board list. `items` (not `data`) — the API response wrapper
|
||||
* already uses `data`, and the frontend's unwrap() strips one `data` level. */
|
||||
export interface BatchBoardListResponse {
|
||||
items: BatchBoardSchedule[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
/** Paginated batch-board list in the shared `{items, meta}` envelope — the API
|
||||
* response wrapper already uses `data`, and the frontend's unwrap() strips one
|
||||
* `data` level. */
|
||||
export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;
|
||||
|
||||
/**
|
||||
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
|
||||
@@ -542,12 +560,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// ---- export FCFS -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Export is first-come-first-serve: no window cycle, no priority, no batch.
|
||||
* Pick the earliest open export train on the booking's corridor/day that still
|
||||
* fits the booking. Throws ConflictException when every train is full — the
|
||||
* staff accept fails and no more export bookings are taken.
|
||||
* Whole-booking single-train space report for an EXPORT booking. Export
|
||||
* bookings never split — the entire booking must ride ONE train, so the
|
||||
* report scans every fillable export train on the booking's corridor/day
|
||||
* (earliest first) for one whose remaining budget fits the whole need. When
|
||||
* none fits, `bestAvailable` carries the largest single-train leftover
|
||||
* converted into the booking's own units (base caps, no overage tolerance)
|
||||
* so the customer can be told exactly how much he COULD book on that day.
|
||||
*/
|
||||
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
|
||||
async exportSpaceReport(
|
||||
booking: Booking,
|
||||
need?: Capacity,
|
||||
): Promise<ExportSpaceReport> {
|
||||
if (!booking.scheduledDate) {
|
||||
throw new BadRequestException('Booking has no scheduled date');
|
||||
}
|
||||
@@ -573,15 +597,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||
);
|
||||
if (!candidates.length) {
|
||||
throw new ConflictException(
|
||||
'No export train is accepting bookings for this day',
|
||||
);
|
||||
}
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const required = need ?? this.needFor(booking, wagonDims);
|
||||
let corridorMatched = false;
|
||||
const dims = this.dimsFor(booking, wagonDims);
|
||||
const report: ExportSpaceReport = {
|
||||
scheduleId: null,
|
||||
trainsForDay: candidates.length > 0,
|
||||
corridorMatched: false,
|
||||
need: required,
|
||||
bestAvailable: null,
|
||||
fullMessage: null,
|
||||
};
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
@@ -592,15 +620,101 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
corridorMatched = true;
|
||||
if (budget.fits(required, leg)) return schedule.id;
|
||||
report.corridorMatched = true;
|
||||
if (budget.fits(required, leg)) {
|
||||
// Earliest fitting train wins — no need to keep sizing leftovers.
|
||||
report.scheduleId = schedule.id;
|
||||
return report;
|
||||
}
|
||||
const available = this.bookableWithin(budget.remainingFor(leg), dims);
|
||||
if (
|
||||
!report.bestAvailable ||
|
||||
available.cargoTons > report.bestAvailable.cargoTons ||
|
||||
(available.cargoTons === report.bestAvailable.cargoTons &&
|
||||
available.wagons > report.bestAvailable.wagons)
|
||||
) {
|
||||
report.bestAvailable = available;
|
||||
}
|
||||
}
|
||||
if (!corridorMatched) {
|
||||
throw new ConflictException(
|
||||
'No export train is accepting bookings for this day',
|
||||
|
||||
report.fullMessage = this.exportFullMessage(booking, report);
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest booking (in the requester's own wagon-type units) that a single
|
||||
* train's leftover base capacity could still admit: bounded by free wagon
|
||||
* slots, free train length, and the locomotive's remaining pull weight
|
||||
* (gross — each wagon's tare eats into it before any cargo does).
|
||||
*/
|
||||
private bookableWithin(
|
||||
remaining: Capacity,
|
||||
dims: PerWagonDims,
|
||||
): { wagons: number; cargoTons: number } {
|
||||
const byLength =
|
||||
dims.lengthMeters > 0
|
||||
? Math.floor(Math.max(0, remaining.lengthMeters) / dims.lengthMeters)
|
||||
: Math.floor(Math.max(0, remaining.wagons));
|
||||
const maxWagons = Math.max(
|
||||
0,
|
||||
Math.min(Math.floor(Math.max(0, remaining.wagons)), byLength),
|
||||
);
|
||||
let bestTons = 0;
|
||||
let usableWagons = 0;
|
||||
for (let w = 1; w <= maxWagons; w++) {
|
||||
if (w * dims.tareWeightTons > remaining.weightTons) break;
|
||||
usableWagons = w;
|
||||
const tons = Math.min(
|
||||
w * dims.capacityTons,
|
||||
remaining.weightTons - w * dims.tareWeightTons,
|
||||
);
|
||||
if (tons > bestTons) bestTons = tons;
|
||||
}
|
||||
return {
|
||||
wagons: usableWagons,
|
||||
cargoTons: Math.max(0, Math.floor(bestTons * 1000) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/** Customer-facing "train is full" copy carrying the bookable leftover. */
|
||||
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
|
||||
if (!report.trainsForDay || !report.corridorMatched) {
|
||||
return 'No export train is accepting bookings for this day';
|
||||
}
|
||||
const best = report.bestAvailable;
|
||||
const base =
|
||||
'Not enough train space — an export booking must ride a single train whole, ' +
|
||||
'and no open train on this day can carry it. ';
|
||||
if (!best || best.wagons <= 0) {
|
||||
return base + 'No capacity is left on this day — pick another shipment day.';
|
||||
}
|
||||
if (booking.freightType === 'BULK') {
|
||||
return (
|
||||
base +
|
||||
`The largest remaining space is about ${best.cargoTons} tons ` +
|
||||
`(${best.wagons} wagon${best.wagons === 1 ? '' : 's'}) — book up to that amount or pick another day.`
|
||||
);
|
||||
}
|
||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||
return (
|
||||
base +
|
||||
`The largest remaining space is ${best.wagons} wagon${best.wagons === 1 ? '' : 's'} ` +
|
||||
`(up to ${best.wagons * 2} × 20ft or ${best.wagons} × 40ft, weight permitting) — ` +
|
||||
'reduce the booking or pick another day.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export is first-come-first-serve: no window cycle, no priority, no batch.
|
||||
* Pick the earliest open export train on the booking's corridor/day that still
|
||||
* fits the booking. Throws ConflictException when every train is full — the
|
||||
* staff accept fails and no more export bookings are taken.
|
||||
*/
|
||||
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
|
||||
const report = await this.exportSpaceReport(booking, need);
|
||||
if (report.scheduleId) return report.scheduleId;
|
||||
throw new ConflictException(
|
||||
report.fullMessage ?? 'Train is full — no export capacity left for this day',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -700,8 +814,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async getBatchBoard(
|
||||
query: BatchBoardQueryDto = {},
|
||||
): Promise<BatchBoardListResponse> {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
// Board cards are heavy (per-schedule booking summaries), so the default
|
||||
// page is smaller than the toolkit-wide 20.
|
||||
const { page, pageSize, skip, take } = normalizePagination(query, {
|
||||
defaultPageSize: 12,
|
||||
});
|
||||
|
||||
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
||||
// arrived / cancelled / dispatched schedules stay visible as history.
|
||||
@@ -763,8 +880,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||
},
|
||||
order: { [sortBy]: sortOrder } as never,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
@@ -800,13 +917,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
board.push(this.buildScheduleSummary(s, items));
|
||||
}
|
||||
|
||||
return {
|
||||
items: board,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||
|
||||
Reference in New Issue
Block a user