Merge pull request #588 from Tria-plc/freight_feature/usermanagement

fix issues
This commit is contained in:
marshal
2026-07-09 23:27:02 +03:00
committed by GitHub
29 changed files with 1143 additions and 147 deletions

View File

@@ -44,6 +44,11 @@ export interface BookingListFilterOptions {
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
scheduledFrom?: string;
scheduledTo?: string;
originYardId?: string;
destinationYardId?: string;
isGovernment?: 'true' | 'false';
consolidationPaired?: string;
}
@@ -814,6 +819,32 @@ export class BookingsRepository extends BaseRepository<Booking> {
createdTo: options.createdTo,
});
}
if (options.scheduledFrom) {
qb.andWhere('booking.scheduled_date >= :scheduledFrom', {
scheduledFrom: options.scheduledFrom,
});
}
if (options.scheduledTo) {
// Inclusive end-of-day: callers pass a date; include the whole day.
qb.andWhere('booking.scheduled_date <= :scheduledTo', {
scheduledTo: options.scheduledTo,
});
}
if (options.originYardId) {
qb.andWhere('booking.origin_yard_id = :originYardId', {
originYardId: options.originYardId,
});
}
if (options.destinationYardId) {
qb.andWhere('booking.destination_yard_id = :destinationYardId', {
destinationYardId: options.destinationYardId,
});
}
if (options.isGovernment === 'true') {
qb.andWhere('booking.is_government = TRUE');
} else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE');
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,

View File

@@ -1182,6 +1182,11 @@ export class BookingsService {
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
scheduledFrom: filter.scheduledFrom,
scheduledTo: filter.scheduledTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
@@ -1397,11 +1402,17 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
scheduledFrom: filter.scheduledFrom,
scheduledTo: filter.scheduledTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired,
};

View File

@@ -81,6 +81,31 @@ export class FilterBookingDto {
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({ description: 'Filter bookings scheduled on/after this date (ISO)' })
@IsOptional()
@IsDateString()
scheduledFrom?: string;
@ApiPropertyOptional({ description: 'Filter bookings scheduled on/before this date (ISO)' })
@IsOptional()
@IsDateString()
scheduledTo?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' })
@IsOptional()
@IsUUID()
originYardId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' })
@IsOptional()
@IsUUID()
destinationYardId?: string;
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
@IsOptional()
@IsIn(['true', 'false'])
isGovernment?: 'true' | 'false';
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])

View File

@@ -11,7 +11,15 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { SchedulerRegistry } from '@nestjs/schedule';
import { DataSource, In } from 'typeorm';
import {
Between,
DataSource,
FindOptionsWhere,
ILike,
In,
LessThanOrEqual,
MoreThanOrEqual,
} from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
@@ -27,6 +35,10 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
} from './dto/batch-board-query.dto';
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
import { BillingService } from "../billing/billing.service";
@@ -158,6 +170,8 @@ export interface BatchWindowGroup {
export interface BatchBoardScheduleDetail {
scheduleId: string;
/** Human-facing schedule reference (S-YYYY-NNNNN). */
scheduleReference: string | null;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
@@ -182,11 +196,14 @@ export interface BatchBoardScheduleDetail {
export interface BatchBoardSchedule {
scheduleId: string;
/** Human-facing schedule reference (S-YYYY-NNNNN). */
scheduleReference: string | null;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
scheduleDate: string | null;
createdAt: string | null;
status: string;
bookingWindowStatus: string;
direction: string | null;
@@ -225,6 +242,16 @@ 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;
}
/**
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
* by priority, greedily fills the train to capacity (skipping bookings that don't fit),
@@ -454,7 +481,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL");
}
@@ -612,7 +639,7 @@ export class BookingBatchService implements OnModuleInit {
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(scheduleId, 'FULL');
}
}
@@ -653,12 +680,70 @@ export class BookingBatchService implements OnModuleInit {
// ---- monitoring board -----------------------------------------------------
/**
* Read model for the batch monitoring page: every still-relevant schedule (not arrived/
* cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle
* state (allocated / awaiting payment / paid-waiting / pending contract / expired).
* Read model for the batch monitoring page: every import schedule — including
* dispatched, arrived and cancelled history — with its locomotive, capacity
* usage and its bookings grouped by lifecycle state (allocated / awaiting
* payment / paid-waiting / pending contract / expired). Paginated and
* filterable; per-schedule booking summaries are only computed for the
* requested page.
*/
async getBatchBoard(): Promise<BatchBoardSchedule[]> {
const schedules = await this.trainSchedulesRepository.findAll({
async getBatchBoard(
query: BatchBoardQueryDto = {},
): Promise<BatchBoardListResponse> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
// arrived / cancelled / dispatched schedules stay visible as history.
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
const statuses = (query.statuses ?? "")
.split(",")
.map((v) => v.trim().toUpperCase())
.filter((v) => allowedStatuses.has(v));
const dateRange = (from?: string, to?: string) => {
const f = from ? new Date(from) : null;
const t = to ? new Date(to) : null;
if (f && t) return Between(f, t);
if (f) return MoreThanOrEqual(f);
if (t) return LessThanOrEqual(t);
return undefined;
};
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
const base: FindOptionsWhere<TrainSchedule> = { direction: "IMPORT" };
if (statuses.length) base.status = In(statuses) as never;
if (query.bookingWindowStatus) {
base.bookingWindowStatus = query.bookingWindowStatus;
}
const departure = dateRange(query.departureFrom, query.departureTo);
if (departure) base.scheduledDepartureDate = departure as never;
const created = dateRange(query.createdFrom, query.createdTo);
if (created) base.createdAt = created 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, 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>[];
}
const sortBy = query.sortBy ?? "createdAt";
const sortOrder = query.sortOrder ?? "DESC";
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
trainSet: { locomotive: true },
originStation: true,
@@ -666,7 +751,9 @@ export class BookingBatchService implements OnModuleInit {
// Yards supply the route's display name for `routeName` below.
route: { originYard: true, destinationYard: true },
},
order: { scheduledDepartureDate: "ASC" },
order: { [sortBy]: sortOrder } as never,
skip: (page - 1) * pageSize,
take: pageSize,
});
const wagonDims = await this.loadWagonDims();
@@ -675,11 +762,6 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
if (s.direction !== "IMPORT") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
@@ -707,7 +789,14 @@ export class BookingBatchService implements OnModuleInit {
board.push(this.buildScheduleSummary(s, items, rules));
}
return board;
return {
items: board,
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
@@ -718,9 +807,8 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s)
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
// Arrived / cancelled schedules stay viewable — the board is also the
// historical record of what each train carried.
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
if (s.direction !== "IMPORT") {
throw new BadRequestException(
@@ -895,6 +983,7 @@ export class BookingBatchService implements OnModuleInit {
return {
scheduleId: s.id,
scheduleReference: s.reference ?? null,
trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
@@ -1016,6 +1105,7 @@ export class BookingBatchService implements OnModuleInit {
return {
scheduleId: s.id,
scheduleReference: s.reference ?? null,
trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
@@ -1024,6 +1114,7 @@ export class BookingBatchService implements OnModuleInit {
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
createdAt: s.createdAt ? s.createdAt.toISOString() : null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
direction: s.direction ?? null,
@@ -1117,7 +1208,8 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
if (budget.maxRemaining().wagons <= 0) {
const minPerWagon = this.minPerWagonNeed(wagonDims);
if (budget.isExhausted(minPerWagon)) {
await this.setWindow(scheduleId, "FULL");
return 0;
}
@@ -1213,7 +1305,7 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
);
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
return commercialReserved;
@@ -1461,8 +1553,9 @@ export class BookingBatchService implements OnModuleInit {
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
);
const minPerWagon = this.minPerWagonNeed(wagonDims);
for (const t of trains) {
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
@@ -1783,7 +1876,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL");
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
@@ -2616,7 +2709,9 @@ export class BookingBatchService implements OnModuleInit {
/**
* Wagon slots still boardable somewhere on the corridor (most-open edge).
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
* ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
* FULL signal is {@link isTrainFull}, which also closes weight/length-bound
* trains that still show free slots.
*/
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims();
@@ -2681,12 +2776,52 @@ export class BookingBatchService implements OnModuleInit {
);
}
/** No wagon slots left for allocated + reserved bookings. */
/**
* FULL on ANY capacity axis: out of wagon slots, or out of pull weight /
* train length for even one more loaded wagon. The old slot-only check let
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of
* 3500+90T, slots bind at 44) cycle its booking window forever instead of
* finalizing — 7 phantom slots kept it "not full" while nothing could board.
*/
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) return false;
return (await this.remainingWagons(schedule)) <= 0;
return this.isTrainFull(schedule);
}
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
if ((await this.remainingWagons(schedule)) <= 0) return true;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
}
/**
* Smallest gross weight / shortest length one more wagon could add: the
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
* so FULL is only declared when not even this wagon fits anywhere.
*/
private minPerWagonNeed(wagonDims: WagonDims): {
grossWeightTons: number;
lengthMeters: number;
} {
const all = [
wagonDims.container,
wagonDims.bulk,
...wagonDims.byWagonTypeId.values(),
];
return {
grossWeightTons: Math.min(
...all.map((d) => d.tareWeightTons + d.capacityTons),
),
lengthMeters: Math.min(...all.map((d) => d.lengthMeters)),
};
}
/**
@@ -2707,7 +2842,10 @@ export class BookingBatchService implements OnModuleInit {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
if ((await this.remainingWagons(schedule)) <= 0) return;
// Symmetric with isScheduleFull: a weight/length-bound FULL is not stale
// just because slots remain — clearing it here would reopen a train
// nothing can board.
if (await this.isTrainFull(schedule)) return;
const customerWindowOpen =
schedule.windowPhase == null || schedule.windowPhase === "OPEN";

View File

@@ -28,6 +28,9 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
* → COMPLETED for intercity), possibly long before the train's final arrival.
* Both are gated on the train's latest recorded checkpoint being at that yard.
* Unload also fires automatically: recording a checkpoint at a yard auto-
* unloads every booking destined there (autoUnloadAtYard), so the manual
* unload endpoint remains only a fallback.
*
* Unloading also settles the physical wagons: each wagon that alights with the
* booking is released at that yard and the move is written to the
@@ -198,6 +201,47 @@ export class BookingJourneyService {
};
}
/**
* Auto-unload on checkpoint: every IN_TRANSIT booking on this schedule whose
* destination is the yard the train just reached alights automatically, so
* the customer's booking flips to ARRIVED (COMPLETED for intercity) the
* moment the train is recorded at their yard — no separate operator unload.
* Runs through the same per-booking unload path (wagon settle + ledger +
* milestones); one booking's failure is logged and never blocks the
* checkpoint or the other bookings. Returns the unloaded booking ids.
*/
async autoUnloadAtYard(
scheduleId: string,
yardId: string,
userId?: string | null,
): Promise<string[]> {
const bookings = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.innerJoin(
'freight.train_schedule_bookings',
'tsb',
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
{ scheduleId },
)
.where('booking.destination_yard_id = :yardId', { yardId })
.andWhere(`booking.status = 'IN_TRANSIT'`)
.getMany();
const unloaded: string[] = [];
for (const booking of bookings) {
try {
await this.unloadBooking(scheduleId, booking.id, userId);
unloaded.push(booking.id);
} catch (err) {
this.logger.warn(
`Auto-unload failed for booking ${booking.id} at yard ${yardId}: ${(err as Error).message}`,
);
}
}
return unloaded;
}
/**
* Bulk fallback at the train's FINAL arrival: any booking destined for the
* final yard that operators didn't unload individually gets its per-booking

View File

@@ -72,4 +72,49 @@ describe('corridor-capacity.util — overage tolerance', () => {
strict.subtract(need(3500, 10, 170), strict.fullLeg());
expect(strict.fits(need(1), strict.fullLeg())).toBe(false);
});
describe('isExhausted — train-wide FULL across all axes', () => {
// Lightest wagon at rated payload: PW2 25.2T tare + 70T = 95.2T gross.
const perWagon = {
grossWeightTons: pw2.tareWeightTons + pw2.capacityTons,
lengthMeters: pw2.lengthMeters,
};
it('reports FULL when weight binds first, with wagon slots still free', () => {
// 37 loaded PW2 wagons = 3522.4T of 3500+90T. 7 length-derived slots
// remain, but wagon 38 would need 95.2T against 67.6T of room — the
// schedule must finalize and its window must disappear.
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(3522.4, 37, 631.442), budget.fullLeg());
expect(budget.maxRemaining().wagons).toBeGreaterThan(0); // slot check alone says "not full"
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('is not FULL while one more loaded wagon still fits within base + tolerance', () => {
const budget = budgetAt(3300); // 200T base room + 90T tolerance ≥ 95.2T
expect(budget.isExhausted(perWagon)).toBe(false);
});
it('reports FULL when wagon slots run out regardless of weight room', () => {
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(1000, 44, 700), budget.fullLeg());
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('reports FULL when length room cannot take one more wagon', () => {
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(1000, 30, 750), budget.fullLeg()); // 10m left < 17.066m
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('only counts an edge as open when EVERY axis has room on that same edge', () => {
// Three stops → two edges. Edge 0 has weight but no slots; edge 1 has
// slots but no weight. Neither can board a wagon, so the train is FULL
// even though the per-axis maxima both look open.
const budget = new CorridorBudget(['a', 'b', 'c'], base, tolerance);
budget.subtract(need(0, 44, 0), { fromEdge: 0, toEdge: 1 });
budget.subtract(need(3522.4, 0, 0), { fromEdge: 1, toEdge: 2 });
expect(budget.isExhausted(perWagon)).toBe(true);
});
});
});

View File

@@ -168,6 +168,25 @@ export class CorridorBudget {
}
}
/**
* Train-wide FULL across ALL capacity axes: true when no edge can board even
* one more loaded wagon. `perWagon` is the smallest gross weight and length
* a future wagon could add (lightest wagon type at rated payload); weight and
* length may dip into the overage tolerance, mirroring {@link fits}. Checked
* per edge — an edge with slots free but no pull weight is just as closed as
* one with no slots. A slot-only check misses weight-bound trains: PW2 at
* 37 × 95.2T = 3522.4T of 3500+90T has 7 length-derived slots free but no
* weight room for wagon 38, and its window must read FULL.
*/
isExhausted(perWagon: { grossWeightTons: number; lengthMeters: number }): boolean {
return this.edges.every(
(e) =>
e.wagons <= 0 ||
e.weightTons + this.tolerance.weightTons < perWagon.grossWeightTons ||
e.lengthMeters + this.tolerance.lengthMeters < perWagon.lengthMeters,
);
}
/**
* The most open edge — when even this has no wagon slots left, nothing can
* board anywhere and the schedule's window is genuinely FULL. (A train can be

View File

@@ -0,0 +1,99 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export const BATCH_BOARD_STATUSES = [
'DRAFT',
'SCHEDULED',
'DISPATCHED',
'ARRIVED',
'CANCELLED',
] as const;
export const BATCH_BOARD_SORT_FIELDS = [
'createdAt',
'scheduledDepartureDate',
'trainNumber',
'status',
] 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;
@ApiPropertyOptional({
description:
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
example: 'DISPATCHED,ARRIVED',
})
@IsOptional()
@IsString()
statuses?: string;
@ApiPropertyOptional({ enum: ['OPEN', 'FULL', 'CLOSED'] })
@IsOptional()
@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()
departureFrom?: string;
@ApiPropertyOptional({ description: 'Departure date upper bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
departureTo?: string;
@ApiPropertyOptional({ description: 'Created-at lower bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Created-at upper bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
createdTo?: string;
@ApiPropertyOptional({ enum: BATCH_BOARD_SORT_FIELDS, default: 'createdAt' })
@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';
}

View File

@@ -39,6 +39,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
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 { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
@@ -124,10 +125,11 @@ export class TrainSchedulingController {
@Get("batch-board")
@TrainSchedulingView()
@ApiOperation({
summary: "Batch monitoring board: schedules with bookings grouped by state",
summary:
"Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state",
})
getBatchBoard() {
return this.bookingBatchService.getBatchBoard();
getBatchBoard(@Query() query: BatchBoardQueryDto) {
return this.bookingBatchService.getBatchBoard(query);
}
@Get("batch-board/:scheduleId")

View File

@@ -2514,6 +2514,12 @@ export class TrainSchedulingService {
if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId);
} else {
// Mid-corridor auto-unload: bookings destined for this yard alight the
// moment the train is recorded here — the yard operator no longer has to
// unload each one by hand. The final station is covered by
// arriveSchedule's bulk fallback above.
await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
}
return this.getScheduleCheckpoints(scheduleId);
@@ -4518,6 +4524,11 @@ export class TrainSchedulingService {
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
// frontend needs it to show the gross train weight.
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: wagon.status,
physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,