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:
Marshal
2026-07-12 10:51:31 +00:00
parent 6695c5448e
commit 4b7f6d2548
108 changed files with 3187 additions and 1368 deletions

View File

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