mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +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:
@@ -0,0 +1,140 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* Export whole-booking single-train gate: an export booking never splits — it
|
||||
* rides one train whole or is rejected. The report must try every fillable
|
||||
* train on the day (first full → use the second), and when none fits, say how
|
||||
* much space is still bookable so the customer knows what he CAN book.
|
||||
*/
|
||||
describe('BookingBatchService — exportSpaceReport (whole-booking, single train)', () => {
|
||||
const DAY = new Date('2026-07-20T10:00:00Z');
|
||||
|
||||
const schedule = (id: string) => ({
|
||||
id,
|
||||
status: 'SCHEDULED',
|
||||
direction: 'EXPORT',
|
||||
scheduledDepartureDate: DAY,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowPhase: null, // legacy gate: OPEN alone makes it fillable
|
||||
});
|
||||
|
||||
const fullGraph = (id: string) => ({
|
||||
id,
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
routeId: null, // legacy two-stop pseudo-route — no milestone query
|
||||
scheduleBookings: [],
|
||||
trainSet: {
|
||||
locomotive: {
|
||||
maxPullWeightTons: 500,
|
||||
maxTrainLengthMeters: 140,
|
||||
overageToleranceTons: 0,
|
||||
overageToleranceMeters: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const exportBooking = (cargoTons: number) =>
|
||||
({
|
||||
id: 'bk-exp',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'EXPORT',
|
||||
scheduledDate: DAY,
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
cargoTotalWeightVgm: cargoTons,
|
||||
bookingContainers: [],
|
||||
}) as unknown as Booking;
|
||||
|
||||
// A reserved bulk booking heavy enough to exhaust the 500t pull budget.
|
||||
const heavyReserved = {
|
||||
id: 'bk-heavy',
|
||||
freightType: 'BULK',
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
cargoTotalWeightVgm: 476,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
let service: BookingBatchService;
|
||||
let trainSchedulesRepository: {
|
||||
findAll: jest.Mock;
|
||||
findByIdWithFullGraph: jest.Mock;
|
||||
findById: jest.Mock;
|
||||
};
|
||||
let bookingsRepository: { findReservedForSchedule: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
trainSchedulesRepository = {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
findByIdWithFullGraph: jest
|
||||
.fn()
|
||||
.mockImplementation(async (id: string) => fullGraph(id)),
|
||||
findById: jest.fn(),
|
||||
};
|
||||
bookingsRepository = { findReservedForSchedule: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
// WagonType.find() → [] so representative default dims apply (bulk 60t
|
||||
// payload / 23.4t tare / 14m); RouteMilestone is never queried (routeId null).
|
||||
const genericRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const dataSource = { getRepository: jest.fn().mockReturnValue(genericRepo) };
|
||||
|
||||
service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
{} as never, // trainScheduleBookingsRepository
|
||||
{} as never, // notifier
|
||||
{} as never, // scheduler
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // billing
|
||||
{} as never, // bookingWindowGateway
|
||||
{} as never, // pricingService
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the other train when the first one is full', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
schedule('train-1'),
|
||||
schedule('train-2'),
|
||||
]);
|
||||
bookingsRepository.findReservedForSchedule.mockImplementation(
|
||||
async (id: string) => (id === 'train-1' ? [heavyReserved] : []),
|
||||
);
|
||||
|
||||
const report = await service.exportSpaceReport(exportBooking(60));
|
||||
|
||||
expect(report.scheduleId).toBe('train-2');
|
||||
});
|
||||
|
||||
it('rejects a booking no single train fits and reports the bookable space', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([schedule('train-1')]);
|
||||
|
||||
const report = await service.exportSpaceReport(exportBooking(900));
|
||||
|
||||
expect(report.scheduleId).toBeNull();
|
||||
expect(report.bestAvailable).not.toBeNull();
|
||||
expect(report.bestAvailable!.cargoTons).toBeGreaterThan(0);
|
||||
expect(report.bestAvailable!.cargoTons).toBeLessThan(900);
|
||||
expect(report.fullMessage).toMatch(/largest remaining space is about .* tons/);
|
||||
expect(report.fullMessage).toMatch(/single train whole/);
|
||||
|
||||
await expect(service.pickExportSchedule(exportBooking(900))).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('says no train is accepting bookings when the day has none', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([]);
|
||||
|
||||
const report = await service.exportSpaceReport(exportBooking(60));
|
||||
|
||||
expect(report.scheduleId).toBeNull();
|
||||
expect(report.fullMessage).toBe(
|
||||
'No export train is accepting bookings for this day',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -6,11 +6,12 @@ import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
|
||||
/**
|
||||
* applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL
|
||||
* (both the parent contract row and the booking's denormalized copy) so the split
|
||||
* remainder can be rebooked. A GENERAL booking is left untouched.
|
||||
* applySplit split-marking behaviour: the reduced booking is flagged is_split
|
||||
* and keeps a pre_split_quantities snapshot (the remainder ledger for ONE_TIME
|
||||
* contracts). The contract kind is NEVER changed — a ONE_TIME contract stays
|
||||
* ONE_TIME through the split chain.
|
||||
*/
|
||||
describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
||||
describe('BookingSplitService — applySplit split marking', () => {
|
||||
const bookingId = 'bk-1';
|
||||
const contractId = 'ct-1';
|
||||
const offerId = 'of-1';
|
||||
@@ -34,6 +35,7 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
||||
id: bookingId,
|
||||
contractId,
|
||||
contractKind: bookingContractKind,
|
||||
cargoTotalWeightVgm: 50,
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -76,22 +78,35 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
||||
return { service, bookingRepo, contractRepo };
|
||||
};
|
||||
|
||||
it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => {
|
||||
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
|
||||
it('flags the reduced booking is_split with a pre-split bulk snapshot', async () => {
|
||||
const { service, bookingRepo } = buildService('ONE_TIME');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
expect(bookingRepo.update).toHaveBeenCalledWith(
|
||||
bookingId,
|
||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||
);
|
||||
expect(contractRepo.update).toHaveBeenCalledWith(
|
||||
contractId,
|
||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||
expect.objectContaining({
|
||||
isSplit: true,
|
||||
preSplitQuantities: { bulkTons: 50 },
|
||||
cargoTotalWeightVgm: 30,
|
||||
wagonsRequired: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a GENERAL booking untouched (no contract promotion)', async () => {
|
||||
it('never changes the contract kind — ONE_TIME stays ONE_TIME', async () => {
|
||||
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
expect(contractRepo.update).not.toHaveBeenCalled();
|
||||
expect(bookingRepo.update).not.toHaveBeenCalledWith(
|
||||
bookingId,
|
||||
expect.objectContaining({ contractKind: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a GENERAL contract untouched too', async () => {
|
||||
const { service, contractRepo } = buildService('GENERAL');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { BillingService } from '../billing/billing.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import {
|
||||
BookingBatchOffer,
|
||||
OfferedLine,
|
||||
@@ -34,11 +33,14 @@ export interface SizedOffer {
|
||||
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
|
||||
* returns to the contract's quantity cap (derived live from booking_container
|
||||
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
||||
* any later window within contract validity. A ONE_TIME contract is promoted to
|
||||
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
|
||||
* Once the remainder is rebooked and the cap hits zero, ContractBookingService
|
||||
* completes the contract (CONTRACT_CLOSED): no further bookings or shipment
|
||||
* requests, even while validity and a booking window are still open.
|
||||
* any later window within contract validity. The reduced booking is flagged
|
||||
* is_split (see applySplit); the contract kind never changes. On a ONE_TIME
|
||||
* contract a split booking releases the single-active-booking slot, but the
|
||||
* next booking must take the WHOLE remainder — the split chain is the only way
|
||||
* a ONE_TIME contract produces multiple bookings. Once the remainder is
|
||||
* rebooked and the cap hits zero, ContractBookingService completes the
|
||||
* contract (CONTRACT_CLOSED): no further bookings or shipment requests, even
|
||||
* while validity and a booking window are still open.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingSplitService {
|
||||
@@ -215,11 +217,26 @@ export class BookingSplitService {
|
||||
if (!offer) return;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
// Snapshot what the booking carried BEFORE the reduction: on a ONE_TIME
|
||||
// contract this is the ledger the outstanding remainder is derived from
|
||||
// (there is no contract quantity cap to fall back on).
|
||||
const preSplit = await manager.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, cargoTotalWeightVgm: true },
|
||||
});
|
||||
const preSplitQuantities: { bulkTons?: number; bySize?: Record<string, number> } = {};
|
||||
|
||||
if (offer.offeredLines?.length) {
|
||||
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
|
||||
const lines = await manager.getRepository(BookingContainer).find({
|
||||
where: { bookingId },
|
||||
});
|
||||
const bySize: Record<string, number> = {};
|
||||
for (const line of lines) {
|
||||
const size = line.containerSize ?? '';
|
||||
bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0);
|
||||
}
|
||||
preSplitQuantities.bySize = bySize;
|
||||
for (const line of lines) {
|
||||
const kept = keptByLine.get(line.id);
|
||||
if (!kept) {
|
||||
@@ -251,34 +268,24 @@ export class BookingSplitService {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
preSplitQuantities.bulkTons = Number(preSplit?.cargoTotalWeightVgm ?? 0);
|
||||
}
|
||||
|
||||
// is_split releases the ONE_TIME single-active-booking slot for the
|
||||
// remainder (whole-remainder-only, enforced at booking creation) and
|
||||
// switches the contract into remainder-based completion. The contract
|
||||
// kind is NOT changed: a ONE_TIME contract stays ONE_TIME through the
|
||||
// split chain.
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
wagonsRequired: offer.offeredWagons,
|
||||
cargoTotalWeightVgm: offer.offeredWeightTons,
|
||||
totalAmount: offer.offeredAmount,
|
||||
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||
isSplit: true,
|
||||
preSplitQuantities,
|
||||
} as never);
|
||||
|
||||
// A ONE_TIME contract permits a single active booking, which would block the
|
||||
// split remainder from ever being rebooked. Promote the parent contract (and
|
||||
// the booking's denormalized copy) to GENERAL so the leftover quantity draws
|
||||
// down against the cap like any general contract, within the same validity.
|
||||
const booking = await manager.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, contractId: true, contractKind: true },
|
||||
});
|
||||
if (booking?.contractKind === 'ONE_TIME') {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { contractKind: 'GENERAL' } as never);
|
||||
if (booking.contractId) {
|
||||
await manager
|
||||
.getRepository(Contract)
|
||||
.update(booking.contractId, { contractKind: 'GENERAL' } as never);
|
||||
}
|
||||
}
|
||||
|
||||
await manager
|
||||
.getRepository(BookingBatchOffer)
|
||||
.update(offer.id, { status: 'APPLIED' });
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { IsIn, IsISO8601, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export const BATCH_BOARD_STATUSES = [
|
||||
'DRAFT',
|
||||
@@ -27,23 +19,13 @@ export const BATCH_BOARD_SORT_FIELDS = [
|
||||
] as const;
|
||||
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
|
||||
|
||||
/** Filters for the batch monitoring board list (import schedules, all statuses). */
|
||||
export class BatchBoardQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
|
||||
/**
|
||||
* Filters for the batch monitoring board list (import schedules, all statuses).
|
||||
* `page`/`pageSize`/`search`/`sortOrder` come from the shared
|
||||
* {@link PaginationQueryDto}; search matches train number, route yards,
|
||||
* stations, or locomotive code (case-insensitive).
|
||||
*/
|
||||
export class BatchBoardQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
|
||||
@@ -58,15 +40,6 @@ export class BatchBoardQueryDto {
|
||||
@IsIn(['OPEN', 'FULL', 'CLOSED'])
|
||||
bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Case-insensitive match on train number, route yards, stations, or locomotive code.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@@ -91,9 +64,4 @@ export class BatchBoardQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
|
||||
sortBy?: BatchBoardSortField;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||
@IsOptional()
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import {
|
||||
TRAIN_SCHEDULE_STATUSES,
|
||||
TrainScheduleStatus,
|
||||
} from '../../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
export const TRAIN_SCHEDULE_SORT_FIELDS = [
|
||||
'createdAt',
|
||||
'scheduledDepartureDate',
|
||||
'reference',
|
||||
'trainNumber',
|
||||
'status',
|
||||
] as const;
|
||||
export type TrainScheduleSortField = (typeof TRAIN_SCHEDULE_SORT_FIELDS)[number];
|
||||
|
||||
/**
|
||||
* Schedules have no freight-type column — the type is DERIVED from the
|
||||
* bookings aboard (see `resolveScheduleFreightType`): a single kind yields
|
||||
* CONTAINER or BULK, both kinds yield MIXED, no bookings yield null (never
|
||||
* matched by this filter).
|
||||
*/
|
||||
export const TRAIN_SCHEDULE_FREIGHT_TYPES = ['CONTAINER', 'BULK', 'MIXED'] as const;
|
||||
export type TrainScheduleFreightType = (typeof TRAIN_SCHEDULE_FREIGHT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Query for the train-schedule list (container/bulk boards). Pagination and
|
||||
* free-text `search` come from the shared {@link PaginationQueryDto}; search
|
||||
* matches schedule reference, train number, route yards, stations, or
|
||||
* locomotive code (case-insensitive). The remaining fields are exact-match
|
||||
* filters that the search never widens.
|
||||
*/
|
||||
export class ListTrainSchedulesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_SORT_FIELDS, default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn(TRAIN_SCHEDULE_SORT_FIELDS as unknown as string[])
|
||||
sortBy?: TrainScheduleSortField;
|
||||
|
||||
/** Lifecycle status (exact match). */
|
||||
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn(TRAIN_SCHEDULE_STATUSES as unknown as string[])
|
||||
status?: TrainScheduleStatus;
|
||||
|
||||
/** Derived freight type of the bookings aboard (exact match). */
|
||||
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_FREIGHT_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[])
|
||||
freightType?: TrainScheduleFreightType;
|
||||
|
||||
/** Origin station/yard id (exact match). */
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originStationId?: string;
|
||||
|
||||
/** Destination station/yard id (exact match). */
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
@@ -743,16 +744,16 @@ export class TrainSchedulingController {
|
||||
|
||||
@Get("container/schedules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List container train schedules" })
|
||||
getContainerTrainSchedules() {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||
@ApiOperation({ summary: "List container train schedules (paginated)" })
|
||||
getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
||||
}
|
||||
|
||||
@Get("bulk/schedules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List bulk train schedules" })
|
||||
getBulkTrainSchedules() {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||
@ApiOperation({ summary: "List bulk train schedules (paginated)" })
|
||||
getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
||||
}
|
||||
|
||||
@Get("container/schedules/:id")
|
||||
|
||||
@@ -17,8 +17,21 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm';
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
In,
|
||||
Not,
|
||||
QueryFailedError,
|
||||
Raw,
|
||||
} from 'typeorm';
|
||||
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
@@ -50,6 +63,10 @@ import { CreateContainerTrainScheduleDto } from './dto/create-container-train-sc
|
||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||
import {
|
||||
ListTrainSchedulesQueryDto,
|
||||
TrainScheduleFreightType,
|
||||
} from './dto/list-train-schedules-query.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||
@@ -2641,8 +2658,42 @@ export class TrainSchedulingService {
|
||||
return Object.assign(detail, { warehouseAutomation });
|
||||
}
|
||||
|
||||
async getContainerTrainSchedules() {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
|
||||
// Exact-match filters (enum/id semantics). Freight type is derived from
|
||||
// the bookings aboard — no column to match — so it rides on `id` as an
|
||||
// EXISTS fragment instead.
|
||||
const base: FindOptionsWhere<TrainSchedule> = {};
|
||||
if (query.status) base.status = query.status;
|
||||
if (query.originStationId) base.originStationId = query.originStationId;
|
||||
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
||||
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
|
||||
|
||||
// Search fans out across every human-recognizable label; each OR variant
|
||||
// repeats the base filters so the search never widens them.
|
||||
const term = query.search?.trim();
|
||||
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
|
||||
base;
|
||||
if (term) {
|
||||
const like = ILike(`%${term}%`);
|
||||
where = [
|
||||
{ ...base, reference: like as never },
|
||||
{ ...base, trainNumber: like as never },
|
||||
{ ...base, originStation: { label: like } },
|
||||
{ ...base, destinationStation: { label: like } },
|
||||
{ ...base, route: { originYard: { label: like } } },
|
||||
{ ...base, route: { destinationYard: { label: like } } },
|
||||
{ ...base, trainSet: { locomotive: { code: like } } },
|
||||
] as FindOptionsWhere<TrainSchedule>[];
|
||||
}
|
||||
|
||||
// Newest-created first (the client can re-sort; this is the default order).
|
||||
const sortBy = query.sortBy ?? 'createdAt';
|
||||
const sortOrder = query.sortOrder ?? 'DESC';
|
||||
|
||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||
// Yards carry the route's display name used by mapScheduleListItem;
|
||||
@@ -2652,10 +2703,14 @@ export class TrainSchedulingService {
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
},
|
||||
// Newest-created first (the client can re-sort; this is the default order).
|
||||
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
|
||||
order: { [sortBy]: sortOrder } as never,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
return schedules.map((s) => this.mapScheduleListItem(s));
|
||||
return {
|
||||
items: schedules.map((s) => this.mapScheduleListItem(s)),
|
||||
meta: buildPaginationMeta(total, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
async getContainerTrainScheduleById(id: string) {
|
||||
@@ -3869,6 +3924,33 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE fragment matching the DERIVED schedule freight type — the SQL mirror
|
||||
* of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER /
|
||||
* BULK = has bookings and every one is that kind; MIXED = both kinds aboard.
|
||||
* Schedules with no bookings (type null) match nothing. Applied to `id` so
|
||||
* the list query stays on findAndCount instead of a query-builder rewrite.
|
||||
*/
|
||||
private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) {
|
||||
const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) =>
|
||||
'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' +
|
||||
'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' +
|
||||
`WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` +
|
||||
`AND b.freight_type ${cmp} :${param})`;
|
||||
if (freightType === 'MIXED') {
|
||||
return Raw(
|
||||
(alias) =>
|
||||
`${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`,
|
||||
{ ftContainer: 'CONTAINER', ftBulk: 'BULK' },
|
||||
);
|
||||
}
|
||||
return Raw(
|
||||
(alias) =>
|
||||
`${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`,
|
||||
{ ftIs: freightType, ftNot: freightType },
|
||||
);
|
||||
}
|
||||
|
||||
private resolveScheduleFreightType(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
||||
|
||||
Reference in New Issue
Block a user