diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts index 717a48217..a651b8f60 100644 --- a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts +++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts @@ -44,13 +44,13 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter // train_set_wagons null their link, wagon_movements cascade. await queryRunner.query(`DELETE FROM freight.wagons;`); - // Wagon.wagonNumber declares `unique: true`, but some environments never got - // the constraint. Repair it here — the table is empty at this point, so the - // index build cannot fail on pre-existing duplicates. - await queryRunner.query(` - CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key - ON freight.wagons (wagon_number); - `); + // Deliberately does NOT create a unique index on wagon_number. It once did, + // to satisfy an ON CONFLICT clause that no longer exists (the DELETE above + // makes collisions impossible). Recreating the plain index here would undo + // WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL + // unique index so soft-deleted wagons stop reserving their number — this + // seeder is run directly by scripts/seed-edr-wagons.ts, which would + // otherwise resurrect the plain index on an already-migrated database. for (const row of FLEET) { if (row.end - row.start + 1 !== row.count) { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 23b517785..c7eecfc02 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -29,6 +29,13 @@ export class ListWagonsQueryDto { @IsUUID() trainId?: string; + @ApiPropertyOptional({ + description: 'Filter by run number — matches export OR import run (e.g. 8001).', + }) + @IsOptional() + @IsString() + trainNumber?: string; + @ApiPropertyOptional({ default: 'wagonNumber' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index a43ff0e67..188bf1783 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -6,7 +6,7 @@ import { ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm'; +import { Repository, DataSource, In } from 'typeorm'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; @@ -44,22 +44,41 @@ export class WagonsService { } async findAll(query: ListWagonsQueryDto = {}): Promise { - const where: FindOptionsWhere[] | FindOptionsWhere = []; const search = query.search?.trim(); const trainId = query.trainId?.trim(); const wagonTypeId = query.wagonTypeId?.trim(); - const filters: FindOptionsWhere = { - ...(query.status ? { status: query.status } : {}), - ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}), - ...(trainId ? { trainId } : {}), - ...(wagonTypeId ? { wagonTypeId } : {}), - }; + const trainNumber = query.trainNumber?.trim(); + // QueryBuilder (not find) because both search and the trainNumber filter span + // two columns each (export/import run) — an OR that FindOptions cannot express + // without cross-producting into conflicting branches. Soft-deleted rows are + // still excluded automatically (BaseEntity's @DeleteDateColumn). + const qb = this.wagonRepo + .createQueryBuilder('w') + .leftJoinAndSelect('w.currentYard', 'currentYard') + .leftJoinAndSelect('w.wagonType', 'wagonType'); + + if (query.status) qb.andWhere('w.status = :status', { status: query.status }); + if (query.currentYardId) + qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId }); + if (trainId) qb.andWhere('w.trainId = :trainId', { trainId }); + if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId }); + + // Filter by run: the odd export run identifies the pair, so match either + // column — a wagon carries export on one, import on the other. + if (trainNumber) { + qb.andWhere( + '(w.exportTrainNumber = :trainNumber OR w.importTrainNumber = :trainNumber)', + { trainNumber }, + ); + } + + // Search matches the wagon number or either run number. if (search) { - where.push({ - wagonNumber: ILike(`%${search}%`), - ...filters, - }); + qb.andWhere( + '(w.wagonNumber ILIKE :search OR w.exportTrainNumber ILIKE :search OR w.importTrainNumber ILIKE :search)', + { search: `%${search}%` }, + ); } // Spec columns (tare, payload) are no longer sortable here — they live on the @@ -75,14 +94,14 @@ export class WagonsService { ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + qb.orderBy(`w.${sortBy}`, sortOrder); - return this.wagonRepo.find({ - where: search ? where : filters, - relations: { currentYard: true, wagonType: true }, - order: { [sortBy]: sortOrder } as FindOptionsOrder, - skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, - take: query.limit ? Number(query.limit) : undefined, - }); + if (query.page && query.limit) { + qb.skip((Number(query.page) - 1) * Number(query.limit)); + } + if (query.limit) qb.take(Number(query.limit)); + + return qb.getMany(); } async findById(id: string): Promise { diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts index 0c8374786..c333abf40 100644 --- a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -2,6 +2,7 @@ import { AppDataSource } from '../data-source'; import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering'; import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers'; import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers'; +import { WagonNumberPartialUnique2280000000000 } from '../migrations/2280000000000-WagonNumberPartialUnique'; import { SeedWagonYardDoraleh2290000000000 } from '../migrations/2290000000000-SeedWagonYardDoraleh'; async function seedEdRWagons() { @@ -19,6 +20,10 @@ async function seedEdRWagons() { // migrate agree. await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner); await new AddWagonTrainNumbers2270000000000().up(queryRunner); + // Not a wagon seed, but it owns wagon_number uniqueness — included so this + // script leaves the same schema a real `migration:run` would, rather than a + // database missing the partial unique index. + await new WagonNumberPartialUnique2280000000000().up(queryRunner); await new SeedWagonRunNumbers2280000000000().up(queryRunner); await new SeedWagonYardDoraleh2290000000000().up(queryRunner); diff --git a/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts b/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts index 775d25aa5..b7af36639 100644 --- a/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts +++ b/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts @@ -41,6 +41,19 @@ export const IMPORT_TRAIN_OPTIONS = Object.values(TRAIN_RUN_PAIRS).map((run) => value: run, })); +/** + * Options for filtering a list by run — one entry per pair, labelled + * "export-import" (8001-8002). The value is the odd EXPORT run, which uniquely + * identifies the pair; the API matches a wagon whose export OR import run + * equals it, so the whole train's wagons come back. + */ +export const TRAIN_RUN_FILTER_OPTIONS = Object.entries(TRAIN_RUN_PAIRS).map( + ([exportRun, importRun]) => ({ + label: `${exportRun}-${importRun}`, + value: exportRun, + }), +); + /** The import run implied by an export run; empty string when unset/unknown. */ export const importRunFor = (exportRun: unknown): string => TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? ""; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 1076d59d8..95dff563a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -59,6 +59,7 @@ const FleetResourcePage = () => { const status = listFilterValues.status; const currentYardId = listFilterValues.currentYardId; const availability = listFilterValues.availability; + const trainNumber = listFilterValues.trainNumber; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } @@ -68,6 +69,9 @@ const FleetResourcePage = () => { if (availability && availability !== "ALL") { (filters as { availability?: string }).availability = availability; } + if (trainNumber && trainNumber !== "ALL") { + (filters as { trainNumber?: string }).trainNumber = trainNumber; + } if (slug !== "locomotives" && search.trim()) { filters.search = search.trim(); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 3981cb47d..caa0ee6c4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -1,6 +1,10 @@ import { Freight } from "@edr/types"; import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources"; -import { IMPORT_TRAIN_OPTIONS, exportRunFor } from "@/constants/trainRuns"; +import { + IMPORT_TRAIN_OPTIONS, + TRAIN_RUN_FILTER_OPTIONS, + exportRunFor, +} from "@/constants/trainRuns"; import { vehiclesConfig, VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS } from "./vehicles"; import { driversConfig, DRIVER_STATUS_OPTIONS } from "./drivers"; @@ -68,7 +72,7 @@ export interface FleetFormFieldDef extends FormFieldDef { } export interface FleetListFilterDef { - key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId"; + key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId" | "trainNumber"; label: string; options?: Array<{ value: string; label: string }>; allLabel?: string; @@ -274,6 +278,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ allLabel: "All yards", dynamicOptions: "yards", }, + { + key: "trainNumber", + label: "Train number", + allLabel: "All trains", + options: TRAIN_RUN_FILTER_OPTIONS, + }, ], cardTitleKey: "wagonNumber", cardSubtitleKey: "currentYard", diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index 524b2f9f6..165d3b594 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -41,6 +41,8 @@ export interface WagonListFilters { currentYardId?: string; wagonTypeId?: string; trainId?: string; + /** Run number — matches a wagon whose export OR import run equals it. */ + trainNumber?: string; } /** @@ -75,6 +77,7 @@ export const wagonService = { if (filters.currentYardId) params.set('currentYardId', filters.currentYardId); if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId); if (filters.trainId) params.set('trainId', filters.trainId); + if (filters.trainNumber) params.set('trainNumber', filters.trainNumber); const qs = params.toString(); return apiClient.get(`/wagons${qs ? `?${qs}` : ''}`); },