mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
Merge pull request #981 from Tria-plc/freight_feature/usermanagement
update locaomotive and fix wagon transfer issue
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import { IsBoolean, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import {
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class FilterLocomotivesDto {
|
||||
// Extends the shared pagination DTO for `page`/`pageSize`/`search`; those are
|
||||
// only read by `GET /locomotives/paged` — the plain list ignores them.
|
||||
export class FilterLocomotivesDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
@@ -47,4 +50,14 @@ export class FilterLocomotivesDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
excludeTrainId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ export class LocomotivesController {
|
||||
return this.locomotivesService.findAll(filter);
|
||||
}
|
||||
|
||||
// Must be declared before @Get(':id') so the path isn't captured as an id.
|
||||
@Get('paged')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List locomotives, paginated ({items, meta})' })
|
||||
findAllPaged(@Query() filter: FilterLocomotivesDto) {
|
||||
return this.locomotivesService.findAllPaged(filter);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a locomotive by ID' })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
|
||||
@@ -16,18 +16,22 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
}
|
||||
|
||||
/**
|
||||
* List locomotives for the train-builder coupling picker: the usual
|
||||
* status/type/yard filters, plus optional exclusion of any loco already
|
||||
* coupled to a built train. `keepTrainId` spares that one train's own locos
|
||||
* from the exclusion so they stay selectable while editing its consist.
|
||||
* Filter/sort builder shared by the coupling picker and the paginated list:
|
||||
* the usual status/type/yard filters, free-text over code + name, a
|
||||
* registration-day range, and optional exclusion of any loco already coupled
|
||||
* to a built train. `keepTrainId` spares that one train's own locos from the
|
||||
* exclusion so they stay selectable while editing its consist.
|
||||
*/
|
||||
findForCoupling(opts: {
|
||||
buildListQuery(opts: {
|
||||
status?: LocomotiveStatus;
|
||||
locomotiveType?: LocomotiveType;
|
||||
currentYardId?: string;
|
||||
excludeCoupled?: boolean;
|
||||
keepTrainId?: string;
|
||||
}): Promise<Locomotive[]> {
|
||||
search?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
}): SelectQueryBuilder<Locomotive> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('locomotive')
|
||||
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
|
||||
@@ -39,6 +43,25 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
if (opts.currentYardId)
|
||||
qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId });
|
||||
|
||||
const search = opts.search?.trim();
|
||||
if (search) {
|
||||
qb.andWhere('(locomotive.code ILIKE :search OR locomotive.name ILIKE :search)', {
|
||||
search: `%${search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// Registration-day range, both ends inclusive (the UI picks whole days).
|
||||
if (opts.createdFrom) {
|
||||
qb.andWhere('locomotive.createdAt >= CAST(:createdFrom AS date)', {
|
||||
createdFrom: opts.createdFrom,
|
||||
});
|
||||
}
|
||||
if (opts.createdTo) {
|
||||
qb.andWhere("locomotive.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
|
||||
createdTo: opts.createdTo,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.excludeCoupled) {
|
||||
// NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the
|
||||
// consist being edited still lists its current locomotives.
|
||||
@@ -53,7 +76,11 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
|
||||
}
|
||||
|
||||
return qb.getMany();
|
||||
return qb;
|
||||
}
|
||||
|
||||
findForCoupling(opts: Parameters<LocomotivesRepository['buildListQuery']>[0]): Promise<Locomotive[]> {
|
||||
return this.buildListQuery(opts).getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { paginateQuery } from '../../common/utils/pagination.util';
|
||||
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
@@ -53,6 +56,21 @@ export class LocomotivesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Same filters as `findAll` plus search/date range, on the shared list envelope. */
|
||||
findAllPaged(filter: FilterLocomotivesDto): Promise<PaginatedResponse<Locomotive>> {
|
||||
const qb = this.locomotivesRepository.buildListQuery({
|
||||
status: filter.status as LocomotiveStatus | undefined,
|
||||
locomotiveType: filter.locomotiveType as LocomotiveType | undefined,
|
||||
currentYardId: filter.currentYardId,
|
||||
excludeCoupled: filter.excludeCoupled,
|
||||
keepTrainId: filter.excludeTrainId,
|
||||
search: filter.search,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
});
|
||||
return paginateQuery(qb, filter);
|
||||
}
|
||||
|
||||
/** Default max pull weight (tons) applied when the caller omits it. */
|
||||
private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500;
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import { RouteStatus } from '../entities/route.entity';
|
||||
|
||||
export class FilterRoutesDto {
|
||||
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
// `search` (origin/destination/milestone yard codes and names) plus
|
||||
// `page`/`pageSize` come from the shared pagination DTO; the page window is only
|
||||
// read by `GET /routes/paged`.
|
||||
export class FilterRoutesDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||
@IsOptional()
|
||||
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||
|
||||
@@ -21,6 +21,13 @@ export class RoutesController {
|
||||
return this.routesService.findAll(filter);
|
||||
}
|
||||
|
||||
// Must be declared before @Get(':id') so the path isn't captured as an id.
|
||||
@Get('paged')
|
||||
@ApiOperation({ summary: 'List routes, paginated ({items, meta})' })
|
||||
findAllPaged(@Query() filter: FilterRoutesDto) {
|
||||
return this.routesService.findAllPaged(filter);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get route by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { TrainScheduleStatus } from '@edr/types';
|
||||
import { PaginatedResponse, TrainScheduleStatus } from '@edr/types';
|
||||
import { DataSource, In, Not } from 'typeorm';
|
||||
|
||||
import { paginateArray } from '../../common/utils/pagination.util';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
|
||||
@@ -68,6 +69,18 @@ export class RoutesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `findAll` on the shared `{items, meta}` envelope.
|
||||
*
|
||||
* ponytail: slices in memory — the corridor table is small (tens of rows) and
|
||||
* both the ordering (formatted "A → B → C" label) and the search span the
|
||||
* milestone collection, which a single SQL page window cannot express. Move to
|
||||
* a query builder if routes ever grow past a few hundred.
|
||||
*/
|
||||
async findAllPaged(filter: FilterRoutesDto): Promise<PaginatedResponse<Route>> {
|
||||
return paginateArray(await this.findAll(filter), filter);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Route> {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id },
|
||||
|
||||
@@ -487,7 +487,13 @@ export class BookingJourneyService {
|
||||
currentYardId: booking.destinationYardId,
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: Freight.WagonStatus.Available,
|
||||
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
|
||||
// only loose wagons return to the open AVAILABLE pool. Marking a
|
||||
// coupled wagon AVAILABLE made it show up in the train-builder's
|
||||
// "available wagons" picker, where attaching it always 409'd.
|
||||
status: wagon.trainId
|
||||
? Freight.WagonStatus.Assigned
|
||||
: Freight.WagonStatus.Available,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListWagonsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search wagon number (partial match)' })
|
||||
@@ -29,6 +39,15 @@ export class ListWagonsQueryDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Only loose wagons (not coupled to a built train) — what a picker can actually take.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
unassigned?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by run number — matches export OR import run (e.g. 8001).',
|
||||
})
|
||||
@@ -60,4 +79,23 @@ export class ListWagonsQueryDto {
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
limit?: number;
|
||||
|
||||
/** Page size for `GET /wagons/paged`; the legacy `limit` still drives `GET /wagons`. */
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ export class WagonTransferRequestsService {
|
||||
currentYardId: yardId,
|
||||
wagonTypeId,
|
||||
status: WagonStatus.Available,
|
||||
// Coupled to a built train = not movable; bulkTransfer rejects it too.
|
||||
trainId: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -399,6 +401,7 @@ export class WagonTransferRequestsService {
|
||||
currentYardId: request.fromYardId,
|
||||
wagonTypeId: request.wagonTypeId,
|
||||
status: WagonStatus.Available,
|
||||
trainId: IsNull(),
|
||||
},
|
||||
order: { wagonNumber: 'ASC' },
|
||||
take: remaining,
|
||||
|
||||
@@ -44,6 +44,14 @@ export class WagonsController {
|
||||
return this.wagonsService.findAll(query);
|
||||
}
|
||||
|
||||
// Must be declared before @Get(':id') so the path isn't captured as an id.
|
||||
@Get('paged')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List wagons, paginated ({items, meta})' })
|
||||
findAllPaged(@Query() query: ListWagonsQueryDto) {
|
||||
return this.wagonsService.findAllPaged(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a wagon by ID' })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, In } from 'typeorm';
|
||||
import { Repository, DataSource, In, SelectQueryBuilder } from 'typeorm';
|
||||
import { paginateQuery } from '../../common/utils/pagination.util';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
@@ -42,7 +43,8 @@ export class WagonsService {
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
||||
/** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */
|
||||
private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder<Wagon> {
|
||||
const search = query.search?.trim();
|
||||
const trainId = query.trainId?.trim();
|
||||
const wagonTypeId = query.wagonTypeId?.trim();
|
||||
@@ -61,6 +63,9 @@ export class WagonsService {
|
||||
if (query.currentYardId)
|
||||
qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
|
||||
if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
|
||||
// Pickers (train-builder, transfer fulfilment) can only take a wagon that is
|
||||
// not already coupled to a built train — never offer one the API will reject.
|
||||
if (query.unassigned) qb.andWhere('w.trainId IS NULL');
|
||||
if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });
|
||||
|
||||
// Filter by run: the odd export run identifies the pair, so match either
|
||||
@@ -72,6 +77,18 @@ export class WagonsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Registration-day range, both ends inclusive (the UI picks whole days).
|
||||
if (query.createdFrom) {
|
||||
qb.andWhere('w.createdAt >= CAST(:createdFrom AS date)', {
|
||||
createdFrom: query.createdFrom,
|
||||
});
|
||||
}
|
||||
if (query.createdTo) {
|
||||
qb.andWhere("w.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
|
||||
createdTo: query.createdTo,
|
||||
});
|
||||
}
|
||||
|
||||
// Search matches the wagon number or either run number.
|
||||
if (search) {
|
||||
qb.andWhere(
|
||||
@@ -95,6 +112,12 @@ export class WagonsService {
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
qb.orderBy(`w.${sortBy}`, sortOrder);
|
||||
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
||||
const qb = this.buildListQuery(query);
|
||||
|
||||
if (query.page && query.limit) {
|
||||
qb.skip((Number(query.page) - 1) * Number(query.limit));
|
||||
}
|
||||
@@ -103,6 +126,11 @@ export class WagonsService {
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/** Same filters as `findAll`, on the shared `{items, meta}` list envelope. */
|
||||
findAllPaged(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||
return paginateQuery(this.buildListQuery(query), query);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Wagon> {
|
||||
const wagon = await this.wagonRepo.findOne({
|
||||
where: { id },
|
||||
|
||||
Reference in New Issue
Block a user