Files
edr-platform/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts
Marshal 6467173c76 fix
2026-07-17 13:57:59 +00:00

141 lines
6.3 KiB
TypeScript

import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope base rail freight to a route (origin yard → destination yard).
*
* Until now a base-freight rate was keyed by direction + container/bulk scope
* only, so "container import" cost the same whether the box was railed to Dire
* Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which
* is what the business actually sells: `container import, Djibouti → Dire Dawa,
* 500 USD`.
*
* Existing base-freight rates predate the yard pair and cannot be backfilled —
* there is no way to know which route each was meant for. They are retired
* (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot
* and rate_change_requests hold FKs to them (RESTRICT) and those rows are price
* history. Retiring drops them out of pricing and the admin UI just the same;
* the yard-scoped replacements must be re-entered.
*
* Surcharges, first-mile and last-mile rates are untouched: they are not
* route-scoped and keep NULL yards.
*/
export class AddRateYardScope2320000000000 implements MigrationInterface {
name = 'AddRateYardScope2320000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Yard columns + FKs ──────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL,
ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL;
`);
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_origin_yard_id"
FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_destination_yard_id"
FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id);
END IF;
END $$;
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`,
);
// ── 2. Retire route-less base freight ──────────────────────────────────
// Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE
// RESTRICT and those snapshots are what past bookings were charged.
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND "trigger" = 'ALWAYS'
AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY');
`);
// ── 3. Route is part of a rate's identity ──────────────────────────────
// Two rates may now share rateType + scope + unit as long as they price
// different legs, so the yard pair joins the uniqueness tuple.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
ON freight.rates (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit
)
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
`);
// ── 4. Base freight must carry a route; nothing else may ───────────────
// Retired rows are exempt — they are the route-less rates step 2 just
// superseded, and they must stay readable for snapshot history.
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// The retired rates are not un-superseded: which route each belonged to was
// never recorded, so reviving them would restore rates that price the wrong
// legs. Down only reverses the schema.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
ON freight.rates (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
rate_unit
)
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`,
);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP COLUMN IF EXISTS destination_yard_id,
DROP COLUMN IF EXISTS origin_yard_id;
`);
}
}