mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 09:00:57 +00:00
chnages
This commit is contained in:
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -13,4 +13,23 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
) {
|
||||
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<Locomotive | null> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<Locomotive> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user