Merge pull request #847 from Tria-plc/freight_feature/usermanagement

chnages
This commit is contained in:
marshal
2026-07-20 20:52:57 +03:00
committed by GitHub
8 changed files with 161 additions and 9 deletions

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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);