complete rule engine and booking flow

This commit is contained in:
marshal
2026-05-30 10:27:59 +03:00
parent 800f036005
commit 430dc44937
74 changed files with 4304 additions and 1248 deletions

View File

@@ -5,7 +5,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
public async up(queryRunner: QueryRunner): Promise<void> {
// Create service_types table
await queryRunner.createTable(
if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable(
new Table({
name: "service_types",
schema: "freight",
@@ -109,7 +109,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
);
// Create cargo_types table
await queryRunner.createTable(
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
new Table({
name: "cargo_types",
schema: "freight",

View File

@@ -13,53 +13,69 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Add `code` column to existing tables ───────────────────────────
await queryRunner.addColumn(
'freight.service_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) {
await queryRunner.addColumn(
'freight.service_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.query(
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL`,
`UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.changeColumn(
'freight.service_types',
'code',
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
await queryRunner.query(
`ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`,
);
await queryRunner.createIndex(
'freight.service_types',
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
const serviceTypesCodeIdx = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`,
);
if (serviceTypesCodeIdx.length === 0) {
await queryRunner.createIndex(
'freight.service_types',
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
);
}
await queryRunner.addColumn(
'freight.cargo_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) {
await queryRunner.addColumn(
'freight.cargo_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.query(
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL`,
`UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.changeColumn(
'freight.cargo_types',
'code',
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
await queryRunner.query(
`ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`,
);
await queryRunner.createIndex(
'freight.cargo_types',
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
const cargoTypesCodeIdx = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`,
);
if (cargoTypesCodeIdx.length === 0) {
await queryRunner.createIndex(
'freight.cargo_types',
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
);
}
// ── 2. surcharge_types ────────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable(
new Table({
name: 'surcharge_types',
schema: 'freight',
@@ -87,7 +103,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
// ── 3. surcharges ─────────────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable(
new Table({
name: 'surcharges',
schema: 'freight',
@@ -136,7 +152,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
// ── 4. container_types ────────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable(
new Table({
name: 'container_types',
schema: 'freight',
@@ -164,7 +180,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
// ── 5. weight_limit_rules ─────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable(
new Table({
name: 'weight_limit_rules',
schema: 'freight',
@@ -229,7 +245,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
// ── 6. priority_rules ─────────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable(
new Table({
name: 'priority_rules',
schema: 'freight',

View File

@@ -0,0 +1,544 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
TableUnique,
} from 'typeorm';
export class ItmlsFullSchemaRewrite1748600000000 implements MigrationInterface {
name = 'ItmlsFullSchemaRewrite1748600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── container_types ───────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.container_types RENAME COLUMN size_code TO code;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.container_types RENAME COLUMN description TO label;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS size_ft SMALLINT,
ADD COLUMN IF NOT EXISTS is_reefer BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_open_top BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS display_order INT NOT NULL DEFAULT 1;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagons_per_unit NUMERIC(4,2);
`);
await queryRunner.query(`
UPDATE freight.container_types
SET wagons_per_unit = CASE
WHEN containers_per_wagon > 0 THEN ROUND(1.0 / containers_per_wagon, 2)
ELSE 1.00
END
WHERE wagons_per_unit IS NULL;
`);
await queryRunner.query(`
UPDATE freight.container_types
SET size_ft = CASE WHEN code LIKE '40%' OR code LIKE '%40%' THEN 40 ELSE 20 END
WHERE size_ft IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ALTER COLUMN wagons_per_unit SET NOT NULL,
DROP COLUMN IF EXISTS containers_per_wagon;
`);
// ── weight_limit_rules ──────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.weight_limit_rules RENAME COLUMN max_weight_tons TO max_vgm_tons;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ALTER COLUMN max_vgm_tons TYPE NUMERIC(8,3);
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ADD COLUMN IF NOT EXISTS effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
ADD COLUMN IF NOT EXISTS effective_to DATE;
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
DROP COLUMN IF EXISTS warning_threshold_tons,
DROP COLUMN IF EXISTS exceeded_action,
DROP COLUMN IF EXISTS surcharge_id,
DROP COLUMN IF EXISTS is_active;
`);
// ── priority_rules ────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.priority_rules
ADD COLUMN IF NOT EXISTS code VARCHAR(40),
ADD COLUMN IF NOT EXISTS label VARCHAR(100),
ADD COLUMN IF NOT EXISTS score INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS condition_currency VARCHAR(5);
`);
await queryRunner.query(`
UPDATE freight.priority_rules
SET code = COALESCE(code, upper(priority_type::text)),
label = COALESCE(label, rule_name),
score = COALESCE(score, bonus_points)
WHERE code IS NULL OR label IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_rules
DROP COLUMN IF EXISTS priority_type,
DROP COLUMN IF EXISTS rule_name,
DROP COLUMN IF EXISTS bonus_points,
DROP COLUMN IF EXISTS activation_condition,
DROP COLUMN IF EXISTS description;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_rules
ALTER COLUMN code SET NOT NULL,
ALTER COLUMN label SET NOT NULL;
`);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'UQ_priority_rules_code', columnNames: ['code'], isUnique: true }),
);
// ── rates (before surcharge_types.rate_id) ────────────────────────────
await queryRunner.createTable(
new Table({
name: 'rates',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'rate_type', type: 'varchar', length: '50' },
{ name: 'container_type_id', type: 'uuid', isNullable: true },
{ name: 'trade_direction', type: 'varchar', length: '10', isNullable: true },
{ name: 'currency', type: 'varchar', length: '5' },
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
{ name: 'rate_unit', type: 'varchar', length: '30' },
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
{ name: 'proposed_by_staff_id', type: 'uuid' },
{ name: 'approved_by_ceo_id', type: 'uuid', isNullable: true },
{ name: 'approved_at', type: 'timestamptz', isNullable: true },
{ name: 'effective_from', type: 'date' },
{ name: 'effective_to', type: 'date', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// ── surcharge_types ───────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.surcharge_types RENAME COLUMN name TO label;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.surcharge_types
ADD COLUMN IF NOT EXISTS trigger_condition VARCHAR(50),
ADD COLUMN IF NOT EXISTS rate_id UUID;
`);
await queryRunner.query(`ALTER TABLE freight.surcharge_types DROP COLUMN IF EXISTS description`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharges CASCADE`);
// ── yards ─────────────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'yards',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '20' },
{ name: 'label', type: 'varchar', length: '100' },
{ name: 'country', type: 'varchar', length: '50' },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'display_order', type: 'int', default: 1 },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.yards',
new TableIndex({ name: 'UQ_yards_code', columnNames: ['code'], isUnique: true }),
);
// ── shipping_lines ────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'shipping_lines',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '20' },
{ name: 'label', type: 'varchar', length: '100' },
{ name: 'mapped_to_code', type: 'varchar', length: '20', isNullable: true },
{ name: 'show_extra_fee_notice', type: 'boolean', default: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// ── approval_rules ────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'approval_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'requires_director_approval', type: 'boolean' },
{ name: 'step_order', type: 'smallint' },
{ name: 'required_role', type: 'varchar', length: '30' },
{ name: 'action_label', type: 'varchar', length: '50' },
{ name: 'blocks_role', type: 'varchar', length: '30', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createUniqueConstraint(
'freight.approval_rules',
new TableUnique({
name: 'UQ_approval_rules_chain_step',
columnNames: ['requires_director_approval', 'step_order'],
}),
);
// ── bookings ────────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS origin_yard_id UUID,
ADD COLUMN IF NOT EXISTS destination_yard_id UUID,
ADD COLUMN IF NOT EXISTS service_type_id UUID,
ADD COLUMN IF NOT EXISTS cargo_type_id UUID,
ADD COLUMN IF NOT EXISTS cargo_free_text VARCHAR(200),
ADD COLUMN IF NOT EXISTS shipping_line_id UUID,
ADD COLUMN IF NOT EXISTS pnr_code VARCHAR(50),
ADD COLUMN IF NOT EXISTS customer_signed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS fully_executed_at TIMESTAMPTZ;
`);
await queryRunner.query(`
INSERT INTO freight.yards (id, code, label, country, is_active, display_order, created_at, updated_at)
VALUES
(uuid_generate_v4(), 'KALITY', 'Kality Rail Terminal', 'Ethiopia', true, 1, now(), now()),
(uuid_generate_v4(), 'MOJO', 'Mojo Dry Port', 'Ethiopia', true, 2, now(), now()),
(uuid_generate_v4(), 'DIRE_DAWA', 'Dire Dawa Yard', 'Ethiopia', true, 3, now(), now()),
(uuid_generate_v4(), 'DJIB_PORT', 'Djibouti Port Terminal', 'Djibouti', true, 4, now(), now()),
(uuid_generate_v4(), 'NAGAD', 'Nagad Terminal, Djibouti', 'Djibouti', true, 5, now(), now()),
(uuid_generate_v4(), 'LEGACY_ORIGIN', 'Legacy Origin', 'Ethiopia', true, 99, now(), now()),
(uuid_generate_v4(), 'LEGACY_DEST', 'Legacy Destination', 'Ethiopia', true, 100, now(), now())
ON CONFLICT (code) DO NOTHING;
`);
await queryRunner.query(`
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
`);
await queryRunner.query(`
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
`);
const hasServiceTypeCol = await queryRunner.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'freight' AND table_name = 'bookings' AND column_name = 'service_type'
LIMIT 1;
`);
if (hasServiceTypeCol.length > 0) {
await queryRunner.query(`
UPDATE freight.bookings b
SET service_type_id = st.id
FROM freight.service_types st
WHERE b.service_type_id IS NULL
AND (
st.code = b.service_type
OR upper(replace(st.service_name, ' ', '_')) = upper(b.service_type)
OR st.code = upper(replace(b.service_type, ' ', '_'))
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET cargo_type_id = ct.id
FROM freight.cargo_types ct
WHERE b.cargo_type_id IS NULL
AND (
ct.code = upper(b.freight_type)
OR ct.code = upper(concat(b.freight_type, '_', coalesce(b.freight_subtype, '')))
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET cargo_free_text = b.freight_subtype
WHERE b.cargo_free_text IS NULL AND b.freight_subtype IS NOT NULL;
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET origin_yard_id = y.id
FROM freight.yards y
WHERE b.origin_yard_id IS NULL
AND (y.label ILIKE b.origin_station OR y.code = upper(replace(b.origin_station, ' ', '_')));
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET destination_yard_id = y.id
FROM freight.yards y
WHERE b.destination_yard_id IS NULL
AND (y.label ILIKE b.destination_station OR y.code = upper(replace(b.destination_station, ' ', '_')));
`);
}
const defaultServiceTypeId = await queryRunner.query(
`SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1`,
);
const defaultCargoTypeId = await queryRunner.query(
`SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1`,
);
const legacyOriginId = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = 'LEGACY_ORIGIN' LIMIT 1`,
);
const legacyDestId = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = 'LEGACY_DEST' LIMIT 1`,
);
if (defaultServiceTypeId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET service_type_id = $1 WHERE service_type_id IS NULL`,
[defaultServiceTypeId[0].id],
);
}
if (defaultCargoTypeId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET cargo_type_id = $1 WHERE cargo_type_id IS NULL`,
[defaultCargoTypeId[0].id],
);
}
if (legacyOriginId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET origin_yard_id = $1 WHERE origin_yard_id IS NULL`,
[legacyOriginId[0].id],
);
}
if (legacyDestId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET destination_yard_id = $1 WHERE destination_yard_id IS NULL`,
[legacyDestId[0].id],
);
}
const nullBookings = await queryRunner.query(
`SELECT COUNT(*)::int AS cnt FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`,
);
if (nullBookings[0]?.cnt > 0) {
await queryRunner.query(`DELETE FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`);
}
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN service_type_id SET NOT NULL,
ALTER COLUMN cargo_type_id SET NOT NULL,
ALTER COLUMN origin_yard_id SET NOT NULL,
ALTER COLUMN destination_yard_id SET NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS origin_station,
DROP COLUMN IF EXISTS destination_station,
DROP COLUMN IF EXISTS service_type,
DROP COLUMN IF EXISTS freight_type,
DROP COLUMN IF EXISTS freight_subtype,
DROP COLUMN IF EXISTS containers,
DROP COLUMN IF EXISTS first_mile_enabled,
DROP COLUMN IF EXISTS last_mile_enabled,
DROP COLUMN IF EXISTS is_refrigerated;
`);
// ── booking_container ─────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'booking_container',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'container_type_id', type: 'uuid' },
{ name: 'quantity', type: 'smallint' },
{ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 },
{ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 },
{ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 },
{ name: 'weight_limit_rule_id', type: 'uuid', isNullable: true },
{ name: 'is_overweight', type: 'boolean', default: false },
{ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_rate_snapshot',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'rate_id', type: 'uuid' },
{ name: 'rate_type', type: 'varchar', length: '50' },
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
{ name: 'rate_unit', type: 'varchar', length: '30' },
{ name: 'currency', type: 'varchar', length: '5' },
{ name: 'snapshotted_at', type: 'timestamptz' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_cargo_modifier',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'surcharge_type_id', type: 'uuid' },
{ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, isNullable: true },
{ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 },
{ name: 'rate_snapshot_id', type: 'uuid' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_approval_step',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'approval_rule_id', type: 'uuid' },
{ name: 'step_order', type: 'smallint' },
{ name: 'required_role', type: 'varchar', length: '30' },
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
{ name: 'actioned_by_staff_id', type: 'uuid', isNullable: true },
{ name: 'actioned_at', type: 'timestamptz', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// Foreign keys
await queryRunner.createForeignKey(
'freight.surcharge_types',
new TableForeignKey({
name: 'FK_surcharge_types_rate_id',
columnNames: ['rate_id'],
referencedTableName: 'rates',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
await queryRunner.createForeignKey(
'freight.booking_container',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'bookings',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.bookings',
new TableForeignKey({
columnNames: ['origin_yard_id'],
referencedTableName: 'yards',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
await queryRunner.createForeignKey(
'freight.bookings',
new TableForeignKey({
columnNames: ['destination_yard_id'],
referencedTableName: 'yards',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_approval_step', true);
await queryRunner.dropTable('freight.booking_cargo_modifier', true);
await queryRunner.dropTable('freight.booking_rate_snapshot', true);
await queryRunner.dropTable('freight.booking_container', true);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS origin_station VARCHAR(255),
ADD COLUMN IF NOT EXISTS destination_station VARCHAR(255),
ADD COLUMN IF NOT EXISTS service_type VARCHAR(30),
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20),
ADD COLUMN IF NOT EXISTS freight_subtype VARCHAR(100),
ADD COLUMN IF NOT EXISTS containers JSONB,
ADD COLUMN IF NOT EXISTS first_mile_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS last_mile_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_refrigerated BOOLEAN DEFAULT false;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS origin_yard_id,
DROP COLUMN IF EXISTS destination_yard_id,
DROP COLUMN IF EXISTS service_type_id,
DROP COLUMN IF EXISTS cargo_type_id,
DROP COLUMN IF EXISTS cargo_free_text,
DROP COLUMN IF EXISTS shipping_line_id,
DROP COLUMN IF EXISTS pnr_code,
DROP COLUMN IF EXISTS customer_signed_at,
DROP COLUMN IF EXISTS fully_executed_at;
`);
await queryRunner.dropTable('freight.approval_rules', true);
await queryRunner.dropTable('freight.shipping_lines', true);
await queryRunner.dropTable('freight.yards', true);
await queryRunner.dropTable('freight.rates', true);
}
}

View File

@@ -0,0 +1,94 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingsConfigForeignKeys1748700000000 implements MigrationInterface {
name = 'AddBookingsConfigForeignKeys1748700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Ensure parent config rows exist for backfill
await queryRunner.query(`
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
`);
await queryRunner.query(`
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
`);
// Clear orphan shipping_line references (nullable FK)
await queryRunner.query(`
UPDATE freight.bookings b
SET shipping_line_id = NULL
WHERE b.shipping_line_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.shipping_lines sl WHERE sl.id = b.shipping_line_id
);
`);
// Backfill required FK columns
await queryRunner.query(`
UPDATE freight.bookings
SET service_type_id = (SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1)
WHERE service_type_id IS NULL
OR NOT EXISTS (SELECT 1 FROM freight.service_types st WHERE st.id = service_type_id);
`);
await queryRunner.query(`
UPDATE freight.bookings
SET cargo_type_id = (SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1)
WHERE cargo_type_id IS NULL
OR NOT EXISTS (SELECT 1 FROM freight.cargo_types ct WHERE ct.id = cargo_type_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_service_type_id"
FOREIGN KEY (service_type_id)
REFERENCES freight.service_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_cargo_type_id"
FOREIGN KEY (cargo_type_id)
REFERENCES freight.cargo_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_shipping_line_id"
FOREIGN KEY (shipping_line_id)
REFERENCES freight.shipping_lines(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_shipping_line_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_cargo_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_service_type_id";
`);
}
}