mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 03:53:39 +00:00
gMerge branch 'dev' of github.com:Tria-plc/edr-platform into dev
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* `company_profiles.status` defaulted to 'active', so any insert that omitted
|
||||
* the column produced an operational role that was approved without ever being
|
||||
* reviewed. Every live write path already passes 'pending' explicitly; this
|
||||
* closes the hole at the schema level.
|
||||
*
|
||||
* Deliberately no data backfill. A role approved through setCompanyProfileStatus
|
||||
* always stamps `reviewed_at`, so `status = 'active' AND reviewed_at IS NULL`
|
||||
* flags a role that skipped review — but it also matches rows approved before
|
||||
* `reviewed_at` existed (migration 2000000000001). Auditing that set is a
|
||||
* judgement call about real customers, not something to automate here.
|
||||
*/
|
||||
export class CompanyProfileDefaultPending2100000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CompanyProfileDefaultPending2100000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Auto-load onto a selected train: a warehouse_loadings row now records WHICH
|
||||
* train the item was loaded onto (train_schedule_id), and wagon_id becomes
|
||||
* nullable because a schedule-level load may not resolve to a single wagon.
|
||||
*/
|
||||
export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface {
|
||||
name = 'WarehouseLoadingTrainAssociation2100000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_loadings
|
||||
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_loadings
|
||||
ALTER COLUMN wagon_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule
|
||||
ON freight.warehouse_loadings(train_schedule_id)
|
||||
WHERE train_schedule_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id
|
||||
`);
|
||||
// wagon_id stays nullable on revert: restoring NOT NULL would fail on rows
|
||||
// recorded without a wagon and re-introduce the outage this fixes.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Partial-batch splits no longer promote a ONE_TIME contract to GENERAL.
|
||||
* Instead the reduced booking is flagged is_split, and the booking gate lets
|
||||
* the customer book exactly the remainder under the still-ONE_TIME contract.
|
||||
*/
|
||||
export class AddBookingIsSplit2110000000000 implements MigrationInterface {
|
||||
name = 'AddBookingIsSplit2110000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS is_split BOOLEAN NOT NULL DEFAULT FALSE
|
||||
`);
|
||||
// Quantities the booking carried before the split — the remainder ledger
|
||||
// for ONE_TIME contracts, which have no quantity cap to derive it from.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS pre_split_quantities JSONB NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_split_quantities
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_split
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Repair migration. `SeparateVehicleAvailability1890000000000` is recorded in
|
||||
* public.migrations but the `availability` column is absent on some databases
|
||||
* (recorded-but-not-applied drift). Because the original is already recorded,
|
||||
* TypeORM will not re-run it, so `vehiclesService.findAll` (a query builder that
|
||||
* selects every entity column) 500s with `column "availability" does not exist`.
|
||||
*
|
||||
* This re-adds the column idempotently and backfills. Safe to run everywhere:
|
||||
* `IF NOT EXISTS` makes it a no-op where the column already exists.
|
||||
*/
|
||||
export class RepairVehicleAvailabilityColumn2110000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "RepairVehicleAvailabilityColumn2110000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// No-op: dropping a column other code now depends on would reintroduce the
|
||||
// drift. The original SeparateVehicleAvailability migration owns the column.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Proof of delivery for EDR last-mile: recipient name, a captured signature
|
||||
* (stored as a file), delivery photos (file ids), notes, and the capture time.
|
||||
* Recorded when the driver completes the delivery.
|
||||
*/
|
||||
export class AddLastMileProofOfDelivery2120000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddLastMileProofOfDelivery2120000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile
|
||||
ADD COLUMN IF NOT EXISTS pod_recipient_name varchar(160),
|
||||
ADD COLUMN IF NOT EXISTS pod_signature_file_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS pod_photo_file_ids text[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS pod_notes text,
|
||||
ADD COLUMN IF NOT EXISTS pod_captured_at timestamptz
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile
|
||||
DROP COLUMN IF EXISTS pod_recipient_name,
|
||||
DROP COLUMN IF EXISTS pod_signature_file_id,
|
||||
DROP COLUMN IF EXISTS pod_photo_file_ids,
|
||||
DROP COLUMN IF EXISTS pod_notes,
|
||||
DROP COLUMN IF EXISTS pod_captured_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add a frozen wagon-allocation snapshot to each train schedule.
|
||||
*
|
||||
* Once a schedule leaves the editable DRAFT/SCHEDULED phase (dispatch / arrive /
|
||||
* cancel), the same physical wagons get released and re-pinned onto later trains.
|
||||
* The live wagon↔slot joins then no longer describe THIS train's plan, so an
|
||||
* admin viewing a past schedule saw a mangled or "unavailable" allocation.
|
||||
*
|
||||
* This jsonb column stores a one-shot frozen copy of the wagon plan (per-slot
|
||||
* physical wagon + booking allocations) captured at the transition. Non-editable
|
||||
* schedules render from the snapshot; DRAFT/SCHEDULED still read live. NULL on
|
||||
* legacy rows and while editable — the read path falls back to the live joins.
|
||||
*/
|
||||
export class AddScheduleWagonAllocationSnapshot2120000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddScheduleWagonAllocationSnapshot2120000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS wagon_allocation_snapshot jsonb;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS wagon_allocation_snapshot;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* The person who signs off a handover must record their full name (a signature
|
||||
* is optional, especially for self-haul). Stored per handover record.
|
||||
*/
|
||||
export class AddHandoverSignerName2130000000000 implements MigrationInterface {
|
||||
name = "AddHandoverSignerName2130000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
ADD COLUMN IF NOT EXISTS signer_name varchar(160)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
DROP COLUMN IF EXISTS signer_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Accrual alert acknowledgements: ops can mark an in-warehouse item's fee
|
||||
* accrual as reviewed (optionally snoozed until a date) so it stops nudging and
|
||||
* drops down the accrual dashboard. One row per inventory item.
|
||||
*/
|
||||
export class CreateAccrualAcks2140000000000 implements MigrationInterface {
|
||||
name = "CreateAccrualAcks2140000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_id uuid NOT NULL UNIQUE,
|
||||
acknowledged_by uuid,
|
||||
acknowledged_at timestamptz NOT NULL DEFAULT now(),
|
||||
snooze_until timestamptz,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Train Builder: a `Train` becomes a first-class buildable consist — a coded
|
||||
* train (e.g. 81001) assembled in one yard from 2+ locomotives and ordered
|
||||
* wagons, then reused by scheduling ("schedule the train" instead of picking
|
||||
* locomotives per departure).
|
||||
*
|
||||
* - `freight.train_locomotives` — link table train ⇄ locomotive with an order
|
||||
* index (mirrors `train_set_locomotives`).
|
||||
* - `trains.current_yard_id` — yard the train sits in; wagons/locomotives may
|
||||
* only be attached from this yard.
|
||||
* - `train_sets.train_id` — which built train an operational set was formed
|
||||
* from, so schedules can surface the train code and the lifecycle can sync
|
||||
* the train's status/yard on dispatch/arrival/cancel.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class TrainBuilder2150000000000 implements MigrationInterface {
|
||||
name = 'TrainBuilder2150000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_locomotives (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
train_id uuid NOT NULL,
|
||||
locomotive_id uuid NOT NULL,
|
||||
sequence_no int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT "PK_train_locomotives" PRIMARY KEY (id),
|
||||
CONSTRAINT "FK_train_locomotives_train" FOREIGN KEY (train_id)
|
||||
REFERENCES freight.trains (id) ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_train_locomotives_locomotive" FOREIGN KEY (locomotive_id)
|
||||
REFERENCES freight.locomotives (id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_locomotives_train_loco"
|
||||
ON freight.train_locomotives (train_id, locomotive_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
ADD COLUMN IF NOT EXISTS current_yard_id uuid;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_trains_current_yard'
|
||||
) THEN
|
||||
ALTER TABLE freight.trains
|
||||
ADD CONSTRAINT "FK_trains_current_yard" FOREIGN KEY (current_yard_id)
|
||||
REFERENCES freight.yards (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_trains_current_yard_id"
|
||||
ON freight.trains (current_yard_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_sets
|
||||
ADD COLUMN IF NOT EXISTS train_id uuid;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_train_sets_train'
|
||||
) THEN
|
||||
ALTER TABLE freight.train_sets
|
||||
ADD CONSTRAINT "FK_train_sets_train" FOREIGN KEY (train_id)
|
||||
REFERENCES freight.trains (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_train_sets_train_id"
|
||||
ON freight.train_sets (train_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_train_sets_train_id";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_sets
|
||||
DROP CONSTRAINT IF EXISTS "FK_train_sets_train",
|
||||
DROP COLUMN IF EXISTS train_id;
|
||||
`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_trains_current_yard_id";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
DROP CONSTRAINT IF EXISTS "FK_trains_current_yard",
|
||||
DROP COLUMN IF EXISTS current_yard_id;
|
||||
`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_train_locomotives_train_loco";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_locomotives;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* A container type / cargo type can now be carried by SEVERAL wagon types
|
||||
* (e.g. a 20ft container rides NX70 or NW5). Replaces the single
|
||||
* `wagon_type_id` FK on both tables with proper link tables; train scheduling
|
||||
* resolves the wagon type from the list, picking whichever type the schedule's
|
||||
* built train (or the yard) actually has.
|
||||
*
|
||||
* Backfills one link row from each existing `wagon_type_id`, then drops the
|
||||
* old column — the single-FK field is removed from the API and UI entirely.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class MultiWagonTypePerCargoAndContainer2160000000000 implements MigrationInterface {
|
||||
name = 'MultiWagonTypePerCargoAndContainer2160000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.container_type_wagon_types (
|
||||
container_type_id uuid NOT NULL,
|
||||
wagon_type_id uuid NOT NULL,
|
||||
CONSTRAINT "PK_container_type_wagon_types" PRIMARY KEY (container_type_id, wagon_type_id),
|
||||
CONSTRAINT "FK_ctwt_container_type" FOREIGN KEY (container_type_id)
|
||||
REFERENCES freight.container_types (id) ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ctwt_wagon_type" FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.cargo_type_wagon_types (
|
||||
cargo_type_id uuid NOT NULL,
|
||||
wagon_type_id uuid NOT NULL,
|
||||
CONSTRAINT "PK_cargo_type_wagon_types" PRIMARY KEY (cargo_type_id, wagon_type_id),
|
||||
CONSTRAINT "FK_cgwt_cargo_type" FOREIGN KEY (cargo_type_id)
|
||||
REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_cgwt_wagon_type" FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
|
||||
// Backfill from the old single FK (column may already be gone on re-run).
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'container_types'
|
||||
AND column_name = 'wagon_type_id'
|
||||
) THEN
|
||||
INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id)
|
||||
SELECT ct.id, ct.wagon_type_id
|
||||
FROM freight.container_types ct
|
||||
WHERE ct.wagon_type_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'cargo_types'
|
||||
AND column_name = 'wagon_type_id'
|
||||
) THEN
|
||||
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
||||
SELECT cg.id, cg.wagon_type_id
|
||||
FROM freight.cargo_types cg
|
||||
WHERE cg.wagon_type_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Old single-FK column is fully retired (API + UI now use the lists).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid
|
||||
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid
|
||||
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT;
|
||||
`);
|
||||
// Restore the first linked wagon type per row, then drop the link tables.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types ct
|
||||
SET wagon_type_id = link.wagon_type_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (container_type_id) container_type_id, wagon_type_id
|
||||
FROM freight.container_type_wagon_types
|
||||
ORDER BY container_type_id, wagon_type_id
|
||||
) link
|
||||
WHERE link.container_type_id = ct.id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.cargo_types cg
|
||||
SET wagon_type_id = link.wagon_type_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (cargo_type_id) cargo_type_id, wagon_type_id
|
||||
FROM freight.cargo_type_wagon_types
|
||||
ORDER BY cargo_type_id, wagon_type_id
|
||||
) link
|
||||
WHERE link.cargo_type_id = cg.id;
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.container_type_wagon_types;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargo_type_wagon_types;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer request queue. A requester records a count-only
|
||||
* request (N wagons of a type, from yard → to yard); OCC staff later pick the
|
||||
* physical wagons and execute the move. Replaces the single-step instant
|
||||
* bulk-transfer as the customer-facing yard-to-yard relocation path.
|
||||
*/
|
||||
export class CreateWagonTransferRequests2170000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateWagonTransferRequests2170000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_transfer_requests (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
from_yard_id uuid NOT NULL,
|
||||
to_yard_id uuid NOT NULL,
|
||||
wagon_type_id uuid NOT NULL,
|
||||
quantity integer NOT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'PENDING',
|
||||
requested_by_user_id uuid NULL,
|
||||
fulfilled_by_user_id uuid NULL,
|
||||
fulfilled_at timestamptz NULL,
|
||||
note text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_wagon_transfer_requests PRIMARY KEY (id),
|
||||
CONSTRAINT fk_wtr_from_yard FOREIGN KEY (from_yard_id) REFERENCES freight.yards (id),
|
||||
CONSTRAINT fk_wtr_to_yard FOREIGN KEY (to_yard_id) REFERENCES freight.yards (id),
|
||||
CONSTRAINT fk_wtr_wagon_type FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types (id),
|
||||
CONSTRAINT chk_wtr_quantity CHECK (quantity > 0)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wtr_status_from_yard
|
||||
ON freight.wagon_transfer_requests (status, from_yard_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_wtr_status_from_yard`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.wagon_transfer_requests`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Consist adjustments from a schedule: staff can trim free wagons off a built
|
||||
* train when their tare pushes gross weight over the locomotives' pull limit
|
||||
* (incl. overage tolerance), or couple extra yard wagons on while weight and
|
||||
* length headroom remain. Each add/remove is logged here so the schedule keeps
|
||||
* an auditable history; the built train itself is updated in place.
|
||||
*
|
||||
* Plain columns (no FKs) so the history survives wagon/train deletion.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface {
|
||||
name = 'ScheduleWagonAdjustmentLogs2170000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
train_schedule_id uuid NOT NULL,
|
||||
train_id uuid NOT NULL,
|
||||
action varchar(10) NOT NULL,
|
||||
wagon_id uuid NOT NULL,
|
||||
wagon_number varchar(50) NOT NULL,
|
||||
adjusted_by_user_id uuid,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id"
|
||||
ON freight.schedule_wagon_adjustment_logs (train_schedule_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_swal_train_id"
|
||||
ON freight.schedule_wagon_adjustment_logs (train_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Link each physical wagon move back to the transfer request that drove it, so
|
||||
* the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103".
|
||||
* Nullable — legacy moves and non-request manual corrections carry no request.
|
||||
* Also indexes `moved_by_user_id` for the per-user history queries.
|
||||
*/
|
||||
export class LinkWagonMovementToTransferRequest2180000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'LinkWagonMovementToTransferRequest2180000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request'
|
||||
) THEN
|
||||
ALTER TABLE freight.wagon_movements
|
||||
ADD CONSTRAINT fk_wm_transfer_request
|
||||
FOREIGN KEY (transfer_request_id)
|
||||
REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wm_transfer_request
|
||||
ON freight.wagon_movements (transfer_request_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wm_moved_by
|
||||
ON freight.wagon_movements (moved_by_user_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
DROP CONSTRAINT IF EXISTS fk_wm_transfer_request
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
DROP COLUMN IF EXISTS transfer_request_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Drop the unused reopen-delay knob from the global rules.
|
||||
*
|
||||
* The window engine never honoured `reopen_delay_minutes`: a not-yet-full train
|
||||
* reopens as soon as its payment phase settles, so the real gap between a cycle
|
||||
* closing and reopening is doc review + payment — nothing else. The per-schedule
|
||||
* `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at
|
||||
* creation so the batch board keeps projecting the cycles the customer was shown.
|
||||
*/
|
||||
export class DropReopenDelayMinutes2190000000000 implements MigrationInterface {
|
||||
name = "DropReopenDelayMinutes2190000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS reopen_delay_minutes;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Every built train owns a fixed pair of run numbers, typed at build time:
|
||||
* an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002).
|
||||
* Scheduling copies the route-direction-matched number onto the schedule at
|
||||
* creation; legacy trains with a null pair keep dispatch-time pool assignment.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class TrainNumberPair2200000000000 implements MigrationInterface {
|
||||
name = 'TrainNumberPair2200000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
ADD COLUMN IF NOT EXISTS import_train_number varchar(20),
|
||||
ADD COLUMN IF NOT EXISTS export_train_number varchar(20);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number"
|
||||
ON freight.trains (import_train_number)
|
||||
WHERE import_train_number IS NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number"
|
||||
ON freight.trains (export_train_number)
|
||||
WHERE export_train_number IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
DROP COLUMN IF EXISTS export_train_number,
|
||||
DROP COLUMN IF EXISTS import_train_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Schedule-scoped wagon pins.
|
||||
*
|
||||
* Wagon occupancy now lives ONLY on each schedule's own train_set_wagons slots
|
||||
* (the per-schedule snapshot): pinning/releasing a wagon no longer mutates the
|
||||
* Wagon entity, so the same physical wagon can serve many schedules (the July 17
|
||||
* and July 20 runs of one train both use its 50 wagons). The Wagon columns
|
||||
* `current_train_schedule_id` / `train_set_wagon_id` keep only their physical
|
||||
* meaning — "out on this DISPATCHED train right now" (stamped at dispatch,
|
||||
* cleared at arrive/unload/cancel).
|
||||
*
|
||||
* This migration erases the legacy pin-time stamps left by the old flow: any
|
||||
* wagon pointing at a schedule that is not currently DISPATCHED (or that no
|
||||
* longer exists) gets its pointers cleared, and — when the old flow had parked
|
||||
* it in ASSIGNED — its status returns to the pool semantics (ASSIGNED only
|
||||
* while coupled to a built train, otherwise AVAILABLE).
|
||||
*/
|
||||
export class ScheduleScopedWagonPins2210000000000 implements MigrationInterface {
|
||||
name = "ScheduleScopedWagonPins2210000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagons w
|
||||
SET current_train_schedule_id = NULL,
|
||||
train_set_wagon_id = NULL,
|
||||
status = CASE
|
||||
WHEN w.status = 'ASSIGNED' AND w.train_id IS NULL THEN 'AVAILABLE'
|
||||
ELSE w.status
|
||||
END
|
||||
WHERE w.deleted_at IS NULL
|
||||
AND w.current_train_schedule_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.train_schedules ts
|
||||
WHERE ts.id = w.current_train_schedule_id
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status = 'DISPATCHED'
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Pin-time stamps cannot be reconstructed (the data was the bug); the
|
||||
// slots on train_set_wagons still hold every live pin, so down is a no-op.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds freight.contracts.document_snapshot — a per-contract frozen copy of the
|
||||
* contract-document template (articles + WHEREAS recitals) captured at staff
|
||||
* accept. Staff can edit these articles for a single contract before generating
|
||||
* its PDF; the edit never touches the shared six freight.contract_templates
|
||||
* rows. Null on existing contracts → the PDF keeps rendering from the live
|
||||
* template, so this is backward compatible.
|
||||
*/
|
||||
export class AddContractDocumentSnapshot2220000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS document_snapshot JSONB;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
DROP COLUMN IF EXISTS document_snapshot;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagon status RETIRED is renamed DETAINED (wagons pulled from circulation).
|
||||
* The column is a plain varchar, so this is a data-only rename. Vehicles keep
|
||||
* their own RETIRED status — only freight.wagons rows are touched.
|
||||
*/
|
||||
export class RenameWagonStatusRetiredToDetained2230000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'RenameWagonStatusRetiredToDetained2230000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Every new wagon-transfer request must state WHY the wagons are needed; the
|
||||
* reason is shown on the OCC request queue. Nullable in the DB — legacy rows
|
||||
* predate the requirement; the DTO enforces it for new requests.
|
||||
*/
|
||||
export class AddTransferRequestReason2240000000000 implements MigrationInterface {
|
||||
name = 'AddTransferRequestReason2240000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_transfer_requests
|
||||
ADD COLUMN IF NOT EXISTS reason text NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_transfer_requests
|
||||
DROP COLUMN IF EXISTS reason
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Approval workflow for priority-rule changes: every create/update/delete of a
|
||||
* priority config is filed here as a PENDING change request; an approver
|
||||
* applies or rejects it. `payload` carries the proposed field values (null for
|
||||
* DELETE), `priority_config_id` the target row (null for CREATE).
|
||||
*/
|
||||
export class CreatePriorityRuleChangeRequests2250000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreatePriorityRuleChangeRequests2250000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.priority_rule_change_requests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
action varchar(10) NOT NULL,
|
||||
priority_config_id uuid NULL REFERENCES freight.priority_configs (id),
|
||||
payload jsonb NULL,
|
||||
status varchar(10) NOT NULL DEFAULT 'PENDING',
|
||||
requested_by_user_id uuid NULL,
|
||||
decided_by_user_id uuid NULL,
|
||||
decided_at timestamptz NULL,
|
||||
decision_note text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_prcr_status
|
||||
ON freight.priority_rule_change_requests (status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.priority_rule_change_requests`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Prepaid customs clearance service fee (Path B):
|
||||
* - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE
|
||||
* fee line so it is billed via its own clearance invoice and excluded from
|
||||
* shipment booking totals;
|
||||
* - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee
|
||||
* settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS);
|
||||
* - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's
|
||||
* fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS).
|
||||
* All nullable/defaulted — existing rows are untouched and keep today's flow.
|
||||
*/
|
||||
export class AddClearanceFeePayment2260000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_rate_snapshots
|
||||
ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS clearance_fee_paid_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
DROP COLUMN IF EXISTS clearance_fee_paid_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_rate_snapshots
|
||||
DROP COLUMN IF EXISTS is_clearance;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* EDR last-mile is multi-truck: a booking can be served by as many trucks as it
|
||||
* has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery
|
||||
* were stamped once per `last_mile` record, so every truck shared one timestamp.
|
||||
* These per-vehicle columns give each EDR truck its own arrival, leaving and
|
||||
* weighed load — the same granularity self-haul trucks already have.
|
||||
*
|
||||
* Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit
|
||||
* weighing UI). Named `*_tons` deliberately: the older
|
||||
* customer_truck_assignments.gross_weight_kg is named kg but stores tonnes.
|
||||
* All nullable — legacy rows predate per-truck tracking.
|
||||
*/
|
||||
export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface {
|
||||
name = 'AddLastMileTruckArrivalDeparture2260000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL
|
||||
`);
|
||||
|
||||
// A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one
|
||||
// container the legacy scalar `container_number` can hold. Mirrors the
|
||||
// self-haul customer_truck_containers child table. The scalar stays in place
|
||||
// (synced to the first container) for backward compatibility.
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE,
|
||||
last_mile_id uuid NOT NULL,
|
||||
container_number varchar(32) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_last_mile_vehicle_containers_assignment"
|
||||
ON freight.last_mile_vehicle_containers (assignment_id)
|
||||
`);
|
||||
// A container rides exactly one truck per delivery.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container"
|
||||
ON freight.last_mile_vehicle_containers (last_mile_id, container_number)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS arrived_at,
|
||||
DROP COLUMN IF EXISTS departed_at,
|
||||
DROP COLUMN IF EXISTS gross_weight_tons,
|
||||
DROP COLUMN IF EXISTS net_weight_tons
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Re-seed the EDR wagon fleet onto the official ER numbering.
|
||||
*
|
||||
* Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons
|
||||
* on a `<CODE>-NNNN` scheme and wrote the status as 'Available' — mixed case
|
||||
* that never matches WagonStatus.Available ('AVAILABLE'), so status filters
|
||||
* silently returned nothing. This seed uses the enum value.
|
||||
*
|
||||
* Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon
|
||||
* specs (capacity/length/tare) stay owned by wagon_types and are not touched —
|
||||
* the types already exist and only the wagon↔type link is (re)established here.
|
||||
*/
|
||||
type FleetRow = {
|
||||
code: string;
|
||||
start: number;
|
||||
end: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/** Official fleet: 1100 wagons, ER0001–ER1100, contiguous across 10 types. */
|
||||
const FLEET: FleetRow[] = [
|
||||
{ code: 'PW2', start: 1, end: 220, count: 220 },
|
||||
{ code: 'CW4', start: 221, end: 330, count: 110 },
|
||||
{ code: 'CW3', start: 331, end: 350, count: 20 },
|
||||
{ code: 'KW2', start: 351, end: 370, count: 20 },
|
||||
{ code: 'KW3', start: 371, end: 390, count: 20 },
|
||||
{ code: 'NW5', start: 391, end: 940, count: 550 },
|
||||
{ code: 'BW1', start: 941, end: 950, count: 10 },
|
||||
{ code: 'GW2', start: 951, end: 1060, count: 110 },
|
||||
{ code: 'NW6', start: 1061, end: 1080, count: 20 },
|
||||
{ code: 'NW7', start: 1081, end: 1100, count: 20 },
|
||||
];
|
||||
|
||||
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
|
||||
|
||||
export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
|
||||
name = 'SeedEdrWagonFleetErNumbering2260000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Full replacement: the ER range is the fleet of record, so any wagon
|
||||
// outside it is stale seed data. Safe to hard-delete — containers and
|
||||
// train_set_wagons null their link, wagon_movements cascade.
|
||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||
|
||||
// Deliberately does NOT create a unique index on wagon_number. It once did,
|
||||
// to satisfy an ON CONFLICT clause that no longer exists (the DELETE above
|
||||
// makes collisions impossible). Recreating the plain index here would undo
|
||||
// WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL
|
||||
// unique index so soft-deleted wagons stop reserving their number — this
|
||||
// seeder is run directly by scripts/seed-edr-wagons.ts, which would
|
||||
// otherwise resurrect the plain index on an already-migrated database.
|
||||
|
||||
for (const row of FLEET) {
|
||||
if (row.end - row.start + 1 !== row.count) {
|
||||
throw new Error(`wagon_range_mismatch:${row.code}`);
|
||||
}
|
||||
|
||||
const [typeRecord] = await queryRunner.query(
|
||||
`SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
|
||||
[row.code],
|
||||
);
|
||||
|
||||
if (!typeRecord?.id) {
|
||||
throw new Error(`wagon_type_missing:${row.code}`);
|
||||
}
|
||||
|
||||
// generate_series builds the range server-side — one round trip per type
|
||||
// instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon
|
||||
// was deleted above, so a plain INSERT cannot collide, and the clause would
|
||||
// otherwise hard-require a unique index this table lacks on some envs.
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.wagons (
|
||||
wagon_number,
|
||||
wagon_type_id,
|
||||
status,
|
||||
current_yard_id,
|
||||
train_id,
|
||||
sequence_number,
|
||||
notes,
|
||||
train_set_wagon_id,
|
||||
current_train_schedule_id
|
||||
)
|
||||
SELECT
|
||||
'ER' || LPAD(seq::text, 4, '0'),
|
||||
$1::uuid,
|
||||
'AVAILABLE',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL
|
||||
FROM generate_series($2::int, $3::int) AS seq;
|
||||
`,
|
||||
[typeRecord.id, row.start, row.end],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
|
||||
[wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds freight.booking_container.return_quantity — how many units of a
|
||||
* container line ship with the empty-container-return service (≤ quantity).
|
||||
* Mirrors hazardous_quantity / reefer_quantity: captured per line at booking
|
||||
* creation when the contract enables WITH_RETURN (container freight only) and
|
||||
* drives the booking-level equipment_return flag that fires the WITH_RETURN
|
||||
* pricing surcharge.
|
||||
*/
|
||||
export class AddContainerReturnQuantity2270000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD COLUMN IF NOT EXISTS return_quantity SMALLINT NOT NULL DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP COLUMN IF EXISTS return_quantity;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form.
|
||||
*
|
||||
* Nullable with no default: a wagon is not on a run until an operator says so.
|
||||
* Mirrors the width of trains.export_train_number / trains.import_train_number
|
||||
* (varchar 20) so the two stay comparable.
|
||||
*/
|
||||
export class AddWagonTrainNumbers2270000000000 implements MigrationInterface {
|
||||
name = 'AddWagonTrainNumbers2270000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS export_train_number varchar(20),
|
||||
ADD COLUMN IF NOT EXISTS import_train_number varchar(20);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP COLUMN IF EXISTS export_train_number,
|
||||
DROP COLUMN IF EXISTS import_train_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Assign EDR export/import run numbers to the wagon fleet.
|
||||
*
|
||||
* Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every
|
||||
* wagon with NULL run numbers — so this must stay later in timestamp order.
|
||||
*
|
||||
* Source data below is the operator-supplied roster, kept verbatim rather than
|
||||
* pre-resolved so its quirks stay visible:
|
||||
* - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50).
|
||||
* - Four wagons are claimed by two runs each. A wagon holds a single run, so
|
||||
* FIRST-LISTED WINS, which is why four runs land one short of their listed
|
||||
* count:
|
||||
* ER0484 8301 over 8401
|
||||
* ER0451 8401 over 8701
|
||||
* ER0887 8701 over 9001
|
||||
* ER0936 8801 over 8901
|
||||
*
|
||||
* Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs.
|
||||
*/
|
||||
|
||||
/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */
|
||||
const RUN_WAGONS: Record<string, string[]> = {
|
||||
'8001': [
|
||||
'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901',
|
||||
'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840',
|
||||
'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694',
|
||||
'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868',
|
||||
'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826',
|
||||
'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825',
|
||||
'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782',
|
||||
'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519',
|
||||
'ER0479', 'ER0440',
|
||||
],
|
||||
'8101': [
|
||||
'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459',
|
||||
'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768',
|
||||
'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937',
|
||||
'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590',
|
||||
'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435',
|
||||
'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633',
|
||||
'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520',
|
||||
'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880',
|
||||
'ER0422', 'ER0852',
|
||||
],
|
||||
'8201': [
|
||||
'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618',
|
||||
'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625',
|
||||
'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231',
|
||||
'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464',
|
||||
'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733',
|
||||
'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588',
|
||||
'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928',
|
||||
'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236',
|
||||
'ER0933', 'ER0456',
|
||||
],
|
||||
'8301': [
|
||||
'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780',
|
||||
'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818',
|
||||
'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485',
|
||||
'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762',
|
||||
'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528',
|
||||
'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232',
|
||||
'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622',
|
||||
'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513',
|
||||
],
|
||||
'8401': [
|
||||
'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758',
|
||||
'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434',
|
||||
'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740',
|
||||
'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787',
|
||||
'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530',
|
||||
'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563',
|
||||
'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442',
|
||||
'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614',
|
||||
'ER0561', 'ER0393',
|
||||
],
|
||||
'8501': [
|
||||
'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748',
|
||||
'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433',
|
||||
'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508',
|
||||
'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572',
|
||||
'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814',
|
||||
'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418',
|
||||
'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702',
|
||||
'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483',
|
||||
'ER0824', 'ER0640', 'ER0714',
|
||||
],
|
||||
'8601': [
|
||||
'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808',
|
||||
'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922',
|
||||
'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496',
|
||||
'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667',
|
||||
'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711',
|
||||
'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487',
|
||||
'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257',
|
||||
],
|
||||
'8701': [
|
||||
'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665',
|
||||
'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582',
|
||||
'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680',
|
||||
'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900',
|
||||
'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726',
|
||||
'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705',
|
||||
'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655',
|
||||
'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861',
|
||||
'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315',
|
||||
'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693',
|
||||
'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518',
|
||||
'ER0887',
|
||||
],
|
||||
'8801': [
|
||||
'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476',
|
||||
'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501',
|
||||
'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601',
|
||||
'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896',
|
||||
'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895',
|
||||
'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610',
|
||||
'ER0275', 'ER0333', 'ER0344', 'ER0469',
|
||||
],
|
||||
'8901': [
|
||||
'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441',
|
||||
'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453',
|
||||
'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866',
|
||||
'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908',
|
||||
'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478',
|
||||
'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497',
|
||||
'ER0643', 'ER0638', 'ER0468', 'ER0597',
|
||||
],
|
||||
'9001': [
|
||||
'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672',
|
||||
'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912',
|
||||
'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399',
|
||||
'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865',
|
||||
'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574',
|
||||
'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699',
|
||||
'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259',
|
||||
'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927',
|
||||
'ER0810', 'ER0681', 'ER0887',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather
|
||||
* than computed as export+1 so a run that ever breaks the convention stays
|
||||
* correct. Run numbers are always 4 digits (8401, never 84001).
|
||||
*/
|
||||
const IMPORT_RUN: Record<string, string> = {
|
||||
'8001': '8002',
|
||||
'8101': '8102',
|
||||
'8201': '8202',
|
||||
'8301': '8302',
|
||||
'8401': '8402',
|
||||
'8501': '8502',
|
||||
'8601': '8602',
|
||||
'8701': '8702',
|
||||
'8801': '8802',
|
||||
'8901': '8902',
|
||||
'9001': '9002',
|
||||
};
|
||||
|
||||
export class SeedWagonRunNumbers2280000000000 implements MigrationInterface {
|
||||
name = 'SeedWagonRunNumbers2280000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Idempotent: clear the roster's runs first so a re-run cannot leave a
|
||||
// wagon on a run it was since moved off of.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagons
|
||||
SET export_train_number = NULL, import_train_number = NULL
|
||||
WHERE export_train_number IS NOT NULL;
|
||||
`);
|
||||
|
||||
const claimed = new Set<string>();
|
||||
|
||||
for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) {
|
||||
const importRun = IMPORT_RUN[exportRun];
|
||||
if (!importRun) throw new Error(`import_run_missing:${exportRun}`);
|
||||
|
||||
// First-listed wins — skip any wagon an earlier run already claimed.
|
||||
const fresh = wagons.filter((w) => !claimed.has(w));
|
||||
fresh.forEach((w) => claimed.add(w));
|
||||
if (!fresh.length) continue;
|
||||
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.wagons
|
||||
SET export_train_number = $1,
|
||||
import_train_number = $2,
|
||||
updated_at = now()
|
||||
WHERE wagon_number = ANY($3::text[]);
|
||||
`,
|
||||
[exportRun, importRun, fresh],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagons
|
||||
SET export_train_number = NULL, import_train_number = NULL
|
||||
WHERE export_train_number IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagons are now soft-deleted (deleted_at) instead of hard-deleted. The plain
|
||||
* UNIQUE on wagon_number would keep a retired wagon's number reserved forever
|
||||
* and block ever re-registering that number. Swap it for a PARTIAL unique index
|
||||
* that only constrains live rows (deleted_at IS NULL); soft-deleted wagons no
|
||||
* longer occupy their number.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. The DO blocks + IF EXISTS/IF NOT EXISTS keep it
|
||||
* idempotent whether the original uniqueness is the auto-named column
|
||||
* constraint (wagons_wagon_number_key) or a TypeORM-named UQ_* constraint/index.
|
||||
*/
|
||||
export class WagonNumberPartialUnique2280000000000 implements MigrationInterface {
|
||||
name = 'WagonNumberPartialUnique2280000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop any UNIQUE constraint on freight.wagons(wagon_number), whatever it is
|
||||
// named (dropping the constraint also drops its backing index).
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE con_name text;
|
||||
BEGIN
|
||||
FOR con_name IN
|
||||
SELECT conname
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'freight.wagons'::regclass
|
||||
AND contype = 'u'
|
||||
AND pg_get_constraintdef(oid) ILIKE '%(wagon_number)%'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS %I', con_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Drop any standalone (non-partial) unique index on wagon_number too.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE idx_name text;
|
||||
BEGIN
|
||||
FOR idx_name IN
|
||||
SELECT c.relname
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
WHERE i.indrelid = 'freight.wagons'::regclass
|
||||
AND i.indisunique
|
||||
AND i.indpred IS NULL
|
||||
AND c.relname <> 'UQ_wagons_wagon_number_active'
|
||||
AND pg_get_indexdef(i.indexrelid) ILIKE '%(wagon_number)%'
|
||||
LOOP
|
||||
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Live wagon numbers stay unique; soft-deleted rows are exempt.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_wagons_wagon_number_active"
|
||||
ON freight.wagons (wagon_number)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// No-op: re-adding a plain UNIQUE would fail whenever two soft-deleted
|
||||
// wagons share a number, and the partial index is strictly safer. Left in
|
||||
// place intentionally.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* container_types.wagons_per_unit is no longer stored: the wagon fraction is
|
||||
* derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per
|
||||
* wagon; see rule-engine/container-type.util.ts). The stored value duplicated
|
||||
* that rule and could silently drift from it.
|
||||
*/
|
||||
export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface {
|
||||
name = 'DropContainerWagonsPerUnit2290000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2);
|
||||
`);
|
||||
// Backfill from the same size rule the code now derives from.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Stand the whole wagon fleet in Doraleh.
|
||||
*
|
||||
* Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every
|
||||
* wagon with a NULL yard — so this must stay later in timestamp order.
|
||||
*
|
||||
* A wagon with no yard cannot be coupled to a train (the train builder only
|
||||
* offers AVAILABLE wagons standing in the train's own yard), which left the
|
||||
* seeded fleet unusable. Doraleh is the Djibouti-side port yard the import runs
|
||||
* originate from.
|
||||
*
|
||||
* The yard is created when absent: environments disagree about which yards
|
||||
* exist, so this cannot assume one is there.
|
||||
*/
|
||||
const YARD_CODE = 'DORALEH';
|
||||
|
||||
export class SeedWagonYardDoraleh2290000000000 implements MigrationInterface {
|
||||
name = 'SeedWagonYardDoraleh2290000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Ensure the yard exists and is usable. Deliberately does NOT overwrite an
|
||||
// existing label/country — a deployment that already calls this yard
|
||||
// something else keeps its own naming.
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.yards (code, label, country, is_active, display_order)
|
||||
VALUES ($1, 'Doraleh', 'Djibouti', true, 12)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
is_active = true,
|
||||
deleted_at = NULL,
|
||||
updated_at = now();
|
||||
`,
|
||||
[YARD_CODE],
|
||||
);
|
||||
|
||||
const [yard] = await queryRunner.query(
|
||||
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
|
||||
[YARD_CODE],
|
||||
);
|
||||
|
||||
if (!yard?.id) {
|
||||
throw new Error(`yard_missing:${YARD_CODE}`);
|
||||
}
|
||||
|
||||
// Whole fleet — a wagon already coupled to a built train follows the train,
|
||||
// so leave those where they stand.
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.wagons
|
||||
SET current_yard_id = $1::uuid,
|
||||
updated_at = now()
|
||||
WHERE train_id IS NULL;
|
||||
`,
|
||||
[yard.id],
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Back to the state SeedEdrWagonFleetErNumbering leaves them in.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagons
|
||||
SET current_yard_id = NULL
|
||||
WHERE train_id IS NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its
|
||||
* destination yard, but only some yards have the equipment to do it. EDR's
|
||||
* load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad —
|
||||
* and the set grows, so it must be data, not a constant.
|
||||
*
|
||||
* `yards.has_facility` marks a yard as a load/unload point; `yard_facilities`
|
||||
* holds what that facility can do. Only a facility with `has_warehouse` (Indode
|
||||
* today) stores cargo, and therefore accrues storage/demurrage — the rest just
|
||||
* move it on and off the train.
|
||||
*
|
||||
* `facility_handling_events` records each load/unload and carries its GRN.
|
||||
* warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so
|
||||
* a facility with no warehouse could never have a row. `inventory_id` links to the
|
||||
* storage record when the facility does have a warehouse.
|
||||
*/
|
||||
export class YardFacilities2290000000000 implements MigrationInterface {
|
||||
name = 'YardFacilities2290000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yards
|
||||
ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.yard_facilities (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||
has_warehouse boolean NOT NULL DEFAULT false,
|
||||
equipment_notes text NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
)
|
||||
`);
|
||||
// One facility record per yard.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard"
|
||||
ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.facility_handling_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
|
||||
yard_id uuid NOT NULL REFERENCES freight.yards(id),
|
||||
train_schedule_id uuid NULL REFERENCES freight.train_schedules(id),
|
||||
event_type varchar(10) NOT NULL,
|
||||
grn_number varchar(60) NULL,
|
||||
quantity numeric(14, 3) NULL,
|
||||
weight_tons numeric(14, 3) NULL,
|
||||
inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id),
|
||||
performed_by varchar(120) NULL,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_booking"
|
||||
ON freight.facility_handling_events (booking_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard"
|
||||
ON freight.facility_handling_events (yard_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn"
|
||||
ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Approval workflow for edits to LIVE rates. A LIVE rate is what pricing
|
||||
* charges, so it is never edited in place: the edit is filed here as PENDING
|
||||
* and the live row keeps its value until an approver applies it.
|
||||
*
|
||||
* `payload` holds the changed fields only; `previous_values` snapshots what
|
||||
* they were at submit time so the approver sees a real before→after diff.
|
||||
*/
|
||||
export class CreateRateChangeRequests2300000000000 implements MigrationInterface {
|
||||
name = 'CreateRateChangeRequests2300000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.rate_change_requests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rate_id uuid NOT NULL REFERENCES freight.rates (id),
|
||||
payload jsonb NOT NULL,
|
||||
previous_values jsonb NOT NULL,
|
||||
status varchar(10) NOT NULL DEFAULT 'PENDING',
|
||||
requested_by_user_id uuid NULL,
|
||||
decided_by_user_id uuid NULL,
|
||||
decided_at timestamptz NULL,
|
||||
decision_note text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rcr_status
|
||||
ON freight.rate_change_requests (status)
|
||||
`);
|
||||
// At most one pending edit per rate — two racing requests would both pass
|
||||
// validation and the second would silently overwrite the first on approval.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate
|
||||
ON freight.rate_change_requests (rate_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Customer-support chat. A `support_conversations` row is the single ongoing
|
||||
* thread with a company; `support_messages` are its text messages. There is no
|
||||
* lifecycle column — a thread is opened by whichever side speaks first and
|
||||
* stays open. Enum-like columns are varchar (no PG enum churn).
|
||||
*
|
||||
* The unique index on `company_id` is load-bearing, not just an optimization:
|
||||
* the get-or-create path depends on it to settle concurrent first-messages.
|
||||
* It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block
|
||||
* a fresh one.
|
||||
*/
|
||||
export class CreateSupportChat2310000000000 implements MigrationInterface {
|
||||
name = "CreateSupportChat2310000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.support_conversations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
company_id uuid NOT NULL,
|
||||
company_name varchar(200),
|
||||
created_by_user_id uuid,
|
||||
last_message_at timestamptz,
|
||||
last_message_preview varchar(280),
|
||||
last_message_author_role varchar(12),
|
||||
customer_last_read_at timestamptz,
|
||||
agent_last_read_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY"
|
||||
ON freight.support_conversations (company_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG"
|
||||
ON freight.support_conversations (last_message_at)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.support_messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id uuid NOT NULL,
|
||||
author_user_id uuid NOT NULL,
|
||||
author_role varchar(12) NOT NULL,
|
||||
author_name varchar(200),
|
||||
body text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_MSG_CONV_CREATED"
|
||||
ON freight.support_messages (conversation_id, created_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.support_conversations`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add a global "booking close offset" — how long BEFORE departure a schedule's
|
||||
* booking window shuts — configurable separately for import and export.
|
||||
*
|
||||
* When an offset is set, the window's close instant is `departure − offset`
|
||||
* (e.g. departure 17:00 with a 3-hour import offset closes at 14:00; departure
|
||||
* Jul-10 16:00 with a 1-day export offset closes Jul-9 16:00). It caps the whole
|
||||
* booking lifecycle: the first window close, every reopen cycle, and the export
|
||||
* FCFS close all land at/at-or-before this cutoff instead of at departure.
|
||||
*
|
||||
* NULL / 0 preserves the previous behaviour exactly (import closes at
|
||||
* open+duration clamped to departure; export closes at departure), so existing
|
||||
* installs are unaffected until an offset is entered.
|
||||
*
|
||||
* `*_close_offset_minutes` on the global-rules singleton is the live config; the
|
||||
* matching `rule_*_close_offset_minutes` snapshot on each schedule freezes it at
|
||||
* creation so the batch board keeps drawing the window the customer was shown
|
||||
* even after a later global-rules edit. Both are nullable with no backfill —
|
||||
* absent means "no offset", the safe default.
|
||||
*/
|
||||
export class AddBookingCloseOffset2330000000000 implements MigrationInterface {
|
||||
name = "AddBookingCloseOffset2330000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN IF NOT EXISTS import_close_offset_minutes integer,
|
||||
ADD COLUMN IF NOT EXISTS export_close_offset_minutes integer;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS rule_import_close_offset_minutes integer,
|
||||
ADD COLUMN IF NOT EXISTS rule_export_close_offset_minutes integer;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS rule_import_close_offset_minutes,
|
||||
DROP COLUMN IF EXISTS rule_export_close_offset_minutes;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS import_close_offset_minutes,
|
||||
DROP COLUMN IF EXISTS export_close_offset_minutes;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add `has_lashing` to cargo types.
|
||||
*
|
||||
* When true, every booking of that cargo type incurs the flat LASHING
|
||||
* surcharge (a rate with trigger = 'LASHING'). Defaults to false so existing
|
||||
* cargo ships without the fee until the flag is turned on.
|
||||
*/
|
||||
export class AddCargoTypeHasLashing2340000000000 implements MigrationInterface {
|
||||
name = "AddCargoTypeHasLashing2340000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS has_lashing boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
DROP COLUMN IF EXISTS has_lashing;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add an opt-in "reverse wagon order" flag to a train schedule.
|
||||
*
|
||||
* When true, the built wagon plan is flipped at build time so the physically-last
|
||||
* wagon sits at position 1. Only the order (sequence_no) changes — composition and
|
||||
* booking allocations travel with their slot. The flag is frozen on the schedule
|
||||
* at creation and re-applied every time the wagon plan is rebuilt, so the stored
|
||||
* train order and the schedule order always match.
|
||||
*
|
||||
* Defaults to false; existing schedules keep their as-built order.
|
||||
*/
|
||||
export class AddReverseWagonOrder2340000000000 implements MigrationInterface {
|
||||
name = "AddReverseWagonOrder2340000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS reverse_wagon_order boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS reverse_wagon_order;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* Refresh the "pricing" article of each seeded contract template so it points
|
||||
* at the live Rate Schedule instead of hardcoded price figures (USD 400/wagon,
|
||||
* USD 919/40ft, …). The original CreateContractTemplates migration seeded the
|
||||
* old prose with ON CONFLICT DO NOTHING, so those figures are frozen in the DB
|
||||
* rows and would otherwise contradict the rate-config-driven schedule table now
|
||||
* rendered under the pricing article.
|
||||
*
|
||||
* Only the article whose id = 'pricing' is touched, and only when its body
|
||||
* still matches the originally-seeded prose — so any admin edit to the pricing
|
||||
* article is left untouched. Idempotent: re-running is a no-op once refreshed.
|
||||
*/
|
||||
export class RefreshContractPricingArticles2350000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
|
||||
const pricing = seed.articles.find((article) => article.id === 'pricing');
|
||||
if (!pricing) continue;
|
||||
|
||||
// jsonb_set the title + body of the element whose id = 'pricing', matched
|
||||
// by array index. Guarded so admin-edited bodies are never overwritten.
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.contract_templates ct
|
||||
SET articles = (
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN elem->>'id' = 'pricing'
|
||||
THEN elem || jsonb_build_object('title', $2::text, 'body', $3::text)
|
||||
ELSE elem
|
||||
END
|
||||
)
|
||||
FROM jsonb_array_elements(ct.articles) elem
|
||||
)
|
||||
WHERE ct.code = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(ct.articles) e
|
||||
WHERE e->>'id' = 'pricing'
|
||||
AND e->>'body' LIKE ANY (ARRAY[
|
||||
'%USD 59.4 per metric ton%',
|
||||
'%USD 696 (six hundred ninety-six) per wagon%',
|
||||
'%USD 400 (four hundred) per wagon%',
|
||||
'%From SGTD to Dire Dawa dry port, the rate is USD 919%',
|
||||
'%Railway transportation charges from GMP to SGTD: USD 819%',
|
||||
'%prevailing EDR domestic container tariff, as set out in the commercial schedule%'
|
||||
])
|
||||
);
|
||||
`,
|
||||
[seed.code, pricing.title, pricing.body],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// No-op: the refreshed pricing prose is the correct forward state; reverting
|
||||
// to hardcoded figures would reintroduce the rate-schedule contradiction.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user