mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
fix(rule-engine): merge duplicate Sebeta yards, block dupes
Two active Sebeta yards (LEGACY_DEST/'Sebeta' and SEBETA/'sebeta') split rates and routes across different yard ids, so route-scoped rate lookups missed. Migration repoints every yard reference to the survivor, retires the duplicate, and adds partial unique indexes on active label and code. Service now rejects case-insensitive duplicate labels on create/update - the old guard only compared generated codes, which missed labels whose existing code differs (LEGACY_DEST). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two active "Sebeta" yards existed (code LEGACY_DEST label "Sebeta", and code
|
||||||
|
* SEBETA label "sebeta") — rates and routes pointed at one or the other, so a
|
||||||
|
* rate configured against one never matched a contract routed via the other.
|
||||||
|
* Merge them: keep the row all rates/distances/facilities reference
|
||||||
|
* (LEGACY_DEST), repoint every yard reference from the duplicate to it, retire
|
||||||
|
* the duplicate, and give the survivor the clean SEBETA code. Then make
|
||||||
|
* duplicate active yard labels/codes impossible at the DB level.
|
||||||
|
*/
|
||||||
|
export class MergeDuplicateSebetaYards3050000000000 implements MigrationInterface {
|
||||||
|
name = "MergeDuplicateSebetaYards3050000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
survivor uuid;
|
||||||
|
dupe uuid;
|
||||||
|
col record;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO survivor FROM freight.yards
|
||||||
|
WHERE code = 'LEGACY_DEST' AND lower(trim(label)) = 'sebeta' AND deleted_at IS NULL;
|
||||||
|
SELECT id INTO dupe FROM freight.yards
|
||||||
|
WHERE code = 'SEBETA' AND deleted_at IS NULL;
|
||||||
|
IF survivor IS NULL OR dupe IS NULL OR survivor = dupe THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Every yard-referencing column in the schema, so rows created between
|
||||||
|
-- authoring and running this migration are repointed too.
|
||||||
|
FOR col IN
|
||||||
|
SELECT table_name, column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'freight'
|
||||||
|
AND table_name <> 'yards'
|
||||||
|
AND (column_name LIKE '%yard_id%' OR column_name LIKE '%station_id%')
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'UPDATE freight.%I SET %I = $1 WHERE %I = $2',
|
||||||
|
col.table_name, col.column_name, col.column_name
|
||||||
|
) USING survivor, dupe;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
UPDATE freight.yards
|
||||||
|
SET code = 'SEBETA@merged', label = 'sebeta@merged', deleted_at = now()
|
||||||
|
WHERE id = dupe;
|
||||||
|
UPDATE freight.yards SET code = 'SEBETA', label = 'Sebeta' WHERE id = survivor;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_label_active"
|
||||||
|
ON freight.yards (lower(trim(label))) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_code_active"
|
||||||
|
ON freight.yards (lower(trim(code))) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Data repair — not reversible. The uniqueness indexes are the new invariant.
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_label_active"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_code_active"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Yard } from '../entities/yard.entity';
|
|||||||
export interface IYardsRepository {
|
export interface IYardsRepository {
|
||||||
findById(id: string): Promise<Yard | null>;
|
findById(id: string): Promise<Yard | null>;
|
||||||
findByCode(code: string): Promise<Yard | null>;
|
findByCode(code: string): Promise<Yard | null>;
|
||||||
|
findByLabelInsensitive(label: string): Promise<Yard | null>;
|
||||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
||||||
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
||||||
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
|
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export class YardsRepository implements IYardsRepository {
|
|||||||
return this.repo.findOne({ where: { code } });
|
return this.repo.findOne({ where: { code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Case/whitespace-insensitive label lookup — backs the duplicate-yard guard. */
|
||||||
|
findByLabelInsensitive(label: string): Promise<Yard | null> {
|
||||||
|
return this.repo
|
||||||
|
.createQueryBuilder('yard')
|
||||||
|
.where('LOWER(TRIM(yard.label)) = LOWER(TRIM(:label))', { label })
|
||||||
|
.andWhere('yard.deleted_at IS NULL')
|
||||||
|
.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]> {
|
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]> {
|
||||||
return this.repo.find(options);
|
return this.repo.find(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { YardsService } from './yards.service';
|
||||||
|
import type { Yard } from '../entities/yard.entity';
|
||||||
|
|
||||||
|
const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard;
|
||||||
|
|
||||||
|
const service = (): YardsService =>
|
||||||
|
new YardsService(
|
||||||
|
{
|
||||||
|
findById: async (id: string) => ({ ...sebeta, id }),
|
||||||
|
findByCode: async () => null,
|
||||||
|
findByLabelInsensitive: async (label: string) =>
|
||||||
|
label.trim().toLowerCase() === 'sebeta' ? sebeta : null,
|
||||||
|
create: async (d: Partial<Yard>) => d as Yard,
|
||||||
|
update: async (_id: string, d: Partial<Yard>) => d as Yard,
|
||||||
|
} as never,
|
||||||
|
{ resolveCreateOrder: async () => 1 } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('duplicate yard labels are rejected', () => {
|
||||||
|
it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => {
|
||||||
|
await expect(
|
||||||
|
service().create({ label: ' sebeta ', country: 'ET' } as never),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks renaming a yard onto another yard label, allows renaming itself', async () => {
|
||||||
|
await expect(
|
||||||
|
service().update('yard-2', { label: 'SEBETA' } as never),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service().update('yard-1', { label: 'Sebeta' } as never),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,6 +31,9 @@ export class YardsService {
|
|||||||
|
|
||||||
/** Create a yard. */
|
/** Create a yard. */
|
||||||
async create(dto: CreateYardDto): Promise<Yard> {
|
async create(dto: CreateYardDto): Promise<Yard> {
|
||||||
|
// Label check first: the code check alone let "sebeta" in next to "Sebeta"
|
||||||
|
// when the existing yard's code didn't match its label (LEGACY_DEST).
|
||||||
|
await this.assertLabelAvailable(dto.label);
|
||||||
const code = generateCode(dto.label).slice(0, 40);
|
const code = generateCode(dto.label).slice(0, 40);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||||
@@ -53,11 +56,20 @@ export class YardsService {
|
|||||||
/** Update a yard. */
|
/** Update a yard. */
|
||||||
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
|
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
|
||||||
const updated = await this.repository.update(id, dto);
|
const updated = await this.repository.update(id, dto);
|
||||||
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No two active yards may share a label (case/whitespace-insensitive). */
|
||||||
|
private async assertLabelAvailable(label: string, exceptId?: string): Promise<void> {
|
||||||
|
const dupe = await this.repository.findByLabelInsensitive(label);
|
||||||
|
if (dupe && dupe.id !== exceptId) {
|
||||||
|
throw new ConflictException(`A yard named "${dupe.label}" already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
||||||
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
|
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
|
||||||
|
|||||||
Reference in New Issue
Block a user