From 61ec67cc2e5bb1c5fde31252cc52c2edb559bdd1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 07:42:02 +0000 Subject: [PATCH] Refactor wagon specifications to rely on wagon type; remove tare weight and max payload from wagon entity and related components --- .../2080000000000-DropWagonSpecColumns.ts | 53 +++++++++++++++++++ .../train-scheduling.service.ts | 2 +- .../modules/wagons/dto/create-wagon.dto.ts | 11 ++-- .../modules/wagons/entities/wagon.entity.ts | 13 +++-- .../src/modules/wagons/wagons.service.ts | 15 ++++-- .../scripts/seed-gate-pass-train-scenarios.ts | 2 - .../seed-negad-indode-arrived-train.ts | 2 - .../src/seed/demo-bookings.seeder.ts | 2 - .../src/seed/demo-freight-data.seeder.ts | 4 -- .../seed/marshalling-demo-trains.seeder.ts | 7 --- .../src/pages/fleet/FleetCrudPages.tsx | 25 +++++---- .../src/pages/fleet/config/resources.ts | 7 +-- .../backoffice/src/services/wagon.service.ts | 6 ++- 13 files changed, 99 insertions(+), 50 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts diff --git a/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts b/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts new file mode 100644 index 000000000..0fe3569f4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagon spec belongs to the wagon TYPE, not to each physical wagon. + * + * `wagons.tare_weight` and `wagons.max_payload_weight` duplicated + * `wagon_types.tare_weight_tons` / `wagon_types.capacity_tons` on all 1100 rows, + * with nothing keeping them in step. They had drifted completely: every wagon + * disagreed with its type's tare (seeded ~20T against a real 22.4T NW5), and a + * third disagreed on payload (NW5 wagons claiming 22T–70T against a flat 70T). + * None of those numbers came from the railway. + * + * Nothing reads them for capacity — that math resolves tare and capacity through + * `wagon_type_id` — so dropping them removes a source of fiction rather than a + * source of truth. `wagon_type_id` is NOT NULL with no orphans, so the type is + * always reachable. + * + * A wagon re-tared after repair would need a nullable override column on + * `wagons` falling back to the type; deliberately not added, since no such + * per-wagon value exists today. + */ +export class DropWagonSpecColumns2080000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS tare_weight, + DROP COLUMN IF EXISTS max_payload_weight; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Re-add nullable, backfill from the owning type, then restore NOT NULL. + // The pre-drop values were drifted seed data and are not recoverable — the + // type's spec is what they should always have held. + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS tare_weight NUMERIC(10, 2), + ADD COLUMN IF NOT EXISTS max_payload_weight NUMERIC(10, 2); + `); + await queryRunner.query(` + UPDATE freight.wagons w + SET tare_weight = t.tare_weight_tons, + max_payload_weight = t.capacity_tons + FROM freight.wagon_types t + WHERE t.id = w.wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + ALTER COLUMN tare_weight SET NOT NULL, + ALTER COLUMN max_payload_weight SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index de57b778b..8ca6ba42d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1900,7 +1900,7 @@ export class TrainSchedulingService { ${esc(wagon.physicalWagon?.wagonNumber)} ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} - ${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))} + ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} ${esc(Number(wagon.capacityTons || 0).toFixed(3))} ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} ${esc(booking?.companyId)} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index 03a930b11..d1939c9f5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -1,5 +1,5 @@ import { WagonStatus } from '@edr/types'; -import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator'; +import { IsString, IsUUID, IsOptional, IsInt, Min, IsEnum } from 'class-validator'; export class CreateWagonDto { @IsString() @@ -17,13 +17,8 @@ export class CreateWagonDto { @Min(1) sequenceNumber?: number; - @IsNumber() - @Min(0) - tareWeight!: number; - - @IsNumber() - @Min(0) - maxPayloadWeight!: number; + // Tare weight and payload capacity are not accepted here: they belong to the + // wagon type and are resolved through wagonTypeId. @IsOptional() @IsEnum(WagonStatus) diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 195b4932b..9f2d41416 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -7,6 +7,7 @@ import { TrainSchedule } from '../../train-schedules/entities/train-schedule.ent import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { Container } from '../../container-management/entities/container.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; export const WAGON_STATUSES = [ WagonStatus.Available, @@ -28,17 +29,19 @@ export class Wagon extends BaseEntity { @Column({ name: 'wagon_type_id', type: 'uuid' }) wagonTypeId!: string; + /** Owns this wagon's spec: tare weight, payload capacity, length. */ + @ManyToOne(() => WagonType) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId!: string | null; @Column({ name: 'sequence_number', type: 'int', nullable: true }) sequenceNumber!: number | null; - @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) - tareWeight!: number; - - @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) - maxPayloadWeight!: number; + // Tare weight and payload capacity are properties of the wagon TYPE — read them + // through `wagonType`, never off the individual wagon. @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; 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 9d1f1b41f..b010c0351 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -52,14 +52,23 @@ export class WagonsService { }); } - const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '') + // Spec columns (tare, payload) are no longer sortable here — they live on the + // wagon type, so sorting by them is sorting by wagonTypeId. + const sortable: Array = [ + 'wagonNumber', + 'status', + 'currentYardId', + 'sequenceNumber', + 'wagonTypeId', + ]; + const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon) ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.wagonRepo.find({ where: search ? where : filters, - relations: { currentYard: true }, + 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, @@ -69,7 +78,7 @@ export class WagonsService { async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, - relations: { currentYard: true }, + relations: { currentYard: true, wagonType: true }, }); if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); return wagon; diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index 4b3479327..a8abee67b 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -419,8 +419,6 @@ async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: nu wagonTypeId, trainId: null, sequenceNumber: sequenceNo, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Assigned, currentYardId: yardId, currentTrainScheduleId: scheduleId, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 35e098f91..4f801330a 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -306,8 +306,6 @@ async function main() { wagonTypeId: wagonType.id, trainId: null, sequenceNumber: sequenceNo, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Assigned, currentYardId: indode.id, notes: 'Demo wagon for Negad to Indode marshalling', diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 570036f24..3720c0e2a 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -504,8 +504,6 @@ export class DemoBookingsSeeder { wagonTypeId: nw5.id, trainId: null, sequenceNumber: null, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Available, currentYardId: index % 2 === 0 ? djibouti.id : addis.id, notes: "Demo wagon for train scheduling", diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index da2da818f..45c374332 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -76,15 +76,11 @@ export class DemoFreightDataSeeder { } const toCreate = MIN_WAGONS_PER_TYPE - existing; - const tare = Number(type.tareWeightTons ?? 20); - const maxPayload = Number(type.capacityTons ?? 60); const rows = Array.from({ length: toCreate }, (_, i) => { const seq = existing + i + 1; return wagonRepo.create({ wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`, wagonTypeId: type.id, - tareWeight: tare, - maxPayloadWeight: maxPayload, status: WagonStatus.Available, }); }); diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts index 53d6c9eec..eec158856 100644 --- a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -203,7 +203,6 @@ export class MarshallingDemoTrainsSeeder { const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0); const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; const wagonLength = Number(refs.wagonType.lengthMeters) || 14; - const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; const trainSet = await trainSetRepo.save( trainSetRepo.create({ @@ -275,8 +274,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: refs.wagonType.id, yardId: originYard.id, trainScheduleId: schedule.id, - tareWeight, - capacityTons: wagonCapacity, dispatched: hasDeparted, }); @@ -420,8 +417,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: string; yardId: string; trainScheduleId: string; - tareWeight: number; - capacityTons: number; dispatched: boolean; }): Promise { const repo = this.dataSource.getRepository(Wagon); @@ -433,8 +428,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: input.wagonTypeId, currentYardId: input.yardId, currentTrainScheduleId: input.trainScheduleId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, status: WagonStatus.Assigned, notes: 'Marshalling demo seed wagon', }), diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 2f8fa67fd..7ce03b96b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -107,6 +107,10 @@ const normalizePayload = (values: Record) => .filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)), ); +/** Render a spec value inherited from the wagon type; em dash when the type isn't loaded. */ +const fmtTypeSpec = (value: number | undefined | null, unit: string) => + value == null ? '—' : `${Number(value)} ${unit}`; + const extractBackendErrors = (error: unknown) => { const responseData = (error as { response?: { data?: unknown } })?.response?.data; const data = responseData && typeof responseData === 'object' ? responseData as Record : undefined; @@ -922,7 +926,17 @@ export function WagonsCrudPage() { ? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})` : '-', }, - { key: 'maxPayloadWeight', label: 'Max payload' }, + { + // Read-only: the spec lives on the wagon type, so it is displayed, never edited here. + key: 'tareWeight', + label: 'Tare weight', + render: (wagon) => fmtTypeSpec(wagon.wagonType?.tareWeightTons, 't'), + }, + { + key: 'maxPayloadWeight', + label: 'Max payload', + render: (wagon) => fmtTypeSpec(wagon.wagonType?.capacityTons, 't'), + }, { key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) }, ]} fields={[ @@ -933,11 +947,6 @@ export function WagonsCrudPage() { type: 'select', required: true, options: wagonTypeOptions, - onValueChange: (value, current) => { - const selectedType = wagonTypes.find((type: any) => type.id === value); - if (!selectedType || Number(current.maxPayloadWeight) > 0) return {}; - return { maxPayloadWeight: Number(selectedType.capacityTons) }; - }, }, { key: 'currentLocationYardId', @@ -946,8 +955,6 @@ export function WagonsCrudPage() { required: true, options: yardOptions, }, - { key: 'tareWeight', label: 'Tare weight', type: 'number', required: true }, - { key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true }, { key: 'status', label: 'Status', @@ -963,7 +970,7 @@ export function WagonsCrudPage() { }, { key: 'notes', label: 'Notes' }, ]} - emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }} + emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', status: 'AVAILABLE', notes: '' }} /> ); } 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 9e6ef7169..091e056b7 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 @@ -263,17 +263,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ cardSubtitleKey: "currentYard", searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"], columns: [ + // Tare weight and payload capacity are not wagon columns — they belong to the + // wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages). { id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" }, { id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" }, - { id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" }, { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], formFields: [ { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, - { name: "tareWeight", label: "Tare weight", type: "number", required: true }, - { name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, { name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS }, { name: "notes", label: "Notes", type: "textarea" }, @@ -281,8 +280,6 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ emptyValues: { wagonNumber: "", wagonTypeId: "", - tareWeight: 0, - maxPayloadWeight: 0, currentYardId: "", status: Freight.WagonStatus.Available, notes: "", 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 a200195e0..e30320988 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -15,14 +15,16 @@ export interface Wagon { label: string; country?: string; } | null; + /** Owns this wagon's spec — tare, capacity, length are read from here, never off the wagon. */ wagonType?: { id: string; code: string; name: string; supportedLoadTypes?: string[]; + tareWeightTons?: number; + capacityTons?: number; + lengthMeters?: number; } | null; - tareWeight: number; - maxPayloadWeight: number; status: Freight.WagonStatus; currentYardId: string | null; currentYard?: { id: string; label?: string; code?: string } | null;