From 26f73cbe5fd2f6cc512098e7652818a07fbcd968 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 20 Jul 2026 13:55:26 +0000 Subject: [PATCH] chnages --- .../2430000000000-UniqueLocomotiveName.ts | 59 +++++++++++++++++++ .../locomotives/entities/locomotive.entity.ts | 7 +++ .../locomotives/locomotives.repository.ts | 19 ++++++ .../locomotives/locomotives.service.ts | 34 ++++++++++- .../wagons/WagonYardWorkspaceModal.tsx | 19 +++++- .../src/pages/fleet/config/resources.ts | 13 +++- .../TrainScheduleV2ListPage.tsx | 10 +++- .../new-contract-form/step2-service-type.tsx | 9 ++- 8 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts diff --git a/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts new file mode 100644 index 000000000..5468e2207 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Locomotive names must be unique so staff can identify a unit by name alone + * (the card view leads with `name`, falling back to `code`). Uniqueness is: + * + * - case/whitespace-insensitive — "MTL1", "mtl1" and " MTL1 " are one name; + * - scoped to live rows — a decommissioned (soft-deleted) locomotive must not + * hold its name hostage, matching how the fleet reuses yard codes; + * - skipped for blank names — `name` stays optional, and NULL/'' rows are + * excluded rather than colliding with each other. + * + * A partial expression index gives all three; a plain UNIQUE column cannot. + */ +export class UniqueLocomotiveName2430000000000 implements MigrationInterface { + name = 'UniqueLocomotiveName2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Pre-existing duplicates would abort CREATE UNIQUE INDEX. Suffix every + // copy after the oldest (…-2, …-3) so the index can build; the oldest row + // keeps the original name. Deterministic on created_at, then id. + await queryRunner.query(` + WITH ranked AS ( + SELECT + id, + name, + row_number() OVER ( + PARTITION BY lower(btrim(name)) + ORDER BY created_at, id + ) AS rn + FROM "freight"."locomotives" + WHERE deleted_at IS NULL + AND name IS NOT NULL + AND btrim(name) <> '' + ) + UPDATE "freight"."locomotives" AS l + SET name = btrim(ranked.name) || '-' || ranked.rn + FROM ranked + WHERE l.id = ranked.id + AND ranked.rn > 1 + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_locomotives_name_active" + ON "freight"."locomotives" (lower(btrim("name"))) + WHERE "deleted_at" IS NULL + AND "name" IS NOT NULL + AND btrim("name") <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."UQ_locomotives_name_active"`, + ); + // The de-duplicating renames are not reversed: the original names are no + // longer recoverable, and restoring them would re-introduce the conflict. + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index d3214b6c4..d7a875c24 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -27,6 +27,13 @@ export class Locomotive extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; + /** + * Optional, but unique when set. Enforced in the DB by the partial expression + * index `UQ_locomotives_name_active` (see UniqueLocomotiveName2430000000000): + * case- and whitespace-insensitive, live rows only, blanks exempt. Not a + * `unique: true` column — that would be case-sensitive and would let a + * soft-deleted locomotive keep holding its name. + */ @Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) name?: string | null; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index af2a40f50..18a42205e 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -13,4 +13,23 @@ export class LocomotivesRepository extends BaseRepository { ) { super(repository); } + + /** + * A live locomotive already holding this name, compared the same way the + * `UQ_locomotives_name_active` index compares: case- and whitespace- + * insensitive, soft-deleted rows excluded. `excludeId` skips the row being + * updated so it can keep its own name. + */ + findByName(name: string, excludeId?: string): Promise { + const qb = this.repository + .createQueryBuilder('locomotive') + .where('lower(btrim(locomotive.name)) = lower(btrim(:name))', { name }); + + if (excludeId) { + qb.andWhere('locomotive.id != :excludeId', { excludeId }); + } + + // createQueryBuilder already filters soft-deleted rows (no withDeleted()). + return qb.getOne(); + } } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 7c0e973e5..cbf9dfc0c 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -60,6 +60,23 @@ export class LocomotivesService { return `LOCO-${String(max + 1).padStart(3, '0')}`; } + /** + * Reject a name already worn by another live locomotive. Compared + * case-insensitively on the trimmed value so this matches the DB index + * `UQ_locomotives_name_active` — otherwise a clash the guard waved through + * would surface as a raw 500 from the index instead of a 409. `excludeId` + * lets an update keep its own name. + */ + private async assertNameAvailable(name: string, excludeId?: string): Promise { + const clash = await this.locomotivesRepository.findByName(name, excludeId); + + if (clash) { + throw new ConflictException( + `Locomotive name "${name.trim()}" is already used by ${clash.code}`, + ); + } + } + async create(dto: CreateLocomotiveDto): Promise { const code = dto.code?.trim() || (await this.generateCode()); @@ -68,9 +85,15 @@ export class LocomotivesService { throw new ConflictException(`Locomotive code ${code} already exists`); } + // Name stays optional; only a non-blank one has to be unique. + const name = dto.name?.trim() || null; + if (name) { + await this.assertNameAvailable(name); + } + return this.locomotivesRepository.create({ code, - name: dto.name?.trim() || null, + name, locomotiveType: dto.locomotiveType as LocomotiveType, status: dto.status as LocomotiveStatus, currentYardId: dto.currentYardId ?? null, @@ -107,6 +130,15 @@ export class LocomotivesService { } } + // Only when the caller actually sends a name — an omitted field keeps the + // current one, and clearing it to blank is allowed. + if (dto.name !== undefined) { + const nextName = dto.name?.trim() || null; + if (nextName) { + await this.assertNameAvailable(nextName, id); + } + } + // A locomotive coupled to a built train follows the train: its yard and // status are owned by the train-builder flow, not this generic PATCH. const link = await this.findTrainLink(id); diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 67841e5f1..c10b1b033 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -205,6 +205,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const availableCount = availableWagons.length; const assignedCount = assignedWagons.length; const otherCount = otherWagons.length; + // Split "Other" so a coupled wagon is visible as such. The Available/Assigned + // buckets deliberately count only UNCOUPLED wagons (see above), so a yard + // holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" — + // accurate for shunting, but unreadable unless the other 53 are named. + const onTrainCount = useMemo( + () => matching.filter((w) => w.trainId != null).length, + [matching], + ); const destinationYardOptions = useMemo( () => @@ -397,9 +405,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro - - - {otherCount > 0 ? : null} + + + {onTrainCount > 0 ? ( + + ) : null} + {otherCount - onTrainCount > 0 ? ( + + ) : null} 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 caa0ee6c4..3487cbed8 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 @@ -128,6 +128,8 @@ const LOCOMOTIVE_STATUS_OPTIONS = [ { label: "Out of service", value: "OUT_OF_SERVICE" }, ]; +// Every status a wagon can hold — for FILTERING the list. ASSIGNED belongs here: +// staff still need to search for assigned wagons. const WAGON_STATUS_OPTIONS = [ { label: "Available", value: Freight.WagonStatus.Available }, { label: "Assigned", value: Freight.WagonStatus.Assigned }, @@ -135,6 +137,15 @@ const WAGON_STATUS_OPTIONS = [ { label: "Detained", value: Freight.WagonStatus.Detained }, ]; +// Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted +// on purpose: a wagon becomes ASSIGNED as a side effect of being built into a +// train, never by editing it directly. Setting it by hand produced wagons that +// claim to be assigned while coupled to nothing, which the yard workspace then +// counts as in-yard stock. +const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter( + (o) => o.value !== Freight.WagonStatus.Assigned, +); + @@ -333,7 +344,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, - { name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS }, + { name: "status", label: "Status", type: "select", required: true, options: WAGON_EDITABLE_STATUS_OPTIONS }, { name: "notes", label: "Notes", type: "textarea" }, ], emptyValues: { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index aae182103..6fbc32cce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -20,7 +20,7 @@ import { useDebouncedValue } from "@mantine/hooks"; import { isAxiosError } from "axios"; import { ArrowRight, - Ban, + // Ban, — used only by the commented-out "Cancel schedule" row action CalendarClock, Clock, Eye, @@ -196,7 +196,7 @@ export default function TrainScheduleV2ListPage() { }), ); const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); - const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); + // const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the // API rejects them, so keep them out of the picker entirely. @@ -464,6 +464,9 @@ export default function TrainScheduleV2ListPage() { Booking window settings ) : null} + {/* Cancel schedule — hidden for now (frontend only; the + cancelSchedule mutation is untouched). Restore by + uncommenting. {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( ) : null} + */} @@ -494,7 +498,7 @@ export default function TrainScheduleV2ListPage() { }, }, ]; - }, [navigate, cancel.isPending, cancel, toast]); + }, [navigate, toast]); const handleCreate = async () => { if (!routeId || !scheduleDate || !trainId) { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx index 866104cda..1c7dfdc7a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx @@ -344,6 +344,11 @@ export function Step2ServiceType({ if (isIntercity && form.getValues("paymentCurrency") !== "ETB") { form.setValue("paymentCurrency", "ETB", { shouldValidate: true }); } + // The customs clearing agent field is hidden for intercity — drop any value + // carried over from a draft or an operation-type switch. + if (isIntercity && form.getValues("customsClearingAgent")) { + form.setValue("customsClearingAgent", "", { shouldDirty: true }); + } }, [isIntercity, form]); return ( @@ -538,7 +543,9 @@ export function Step2ServiceType({ )} - {includesCustoms ? ( + {/* Intercity (domestic) moves never cross a border, so no customs + clearing agent is collected. */} + {isIntercity ? null : includesCustoms ? (