feat: empty container Import

This commit is contained in:
hager
2026-09-04 22:43:20 +00:00
parent 2b6c78cf71
commit 3fb5cdf29f
36 changed files with 968 additions and 49 deletions

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Empty container import is base rail freight for equipment carrying no cargo,
* so it is sold per lane exactly like laden container freight.
*
* CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an
* empty rate prices a leg (Djibouti -> Modjo), so both yards stay required.
* Drop-and-recreate is the established shape for this constraint — see
* 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance.
*/
export class EmptyContainerRateScope3890000000000 implements MigrationInterface {
name = 'EmptyContainerRateScope3890000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
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', 'EMPTY_CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
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
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
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'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
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
)
`);
}
}

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Whether a booking moves cargo or bare equipment.
*
* EMPTY is container freight carrying nothing — the box itself is the shipment,
* priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a
* separate column rather than a third `freight_type`: an empty booking is still
* CONTAINER freight for wagon footprint, yard and warehouse allocation, train
* scheduling, marshalling and gate passes, and `freight_type` is read in ~880
* places whose else-arm means "container".
*
* Every existing row is LADEN, which the default supplies — no backfill needed.
*/
export class BookingCargoCondition3900000000000 implements MigrationInterface {
name = 'BookingCargoCondition3900000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"
`);
// Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER
// freight. Enforced here so no API path can file the combination.
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK (
cargo_condition IN ('LADEN', 'EMPTY')
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
)
`);
// The booking queues filter empties out of (and into) the laden lists.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition
ON freight.bookings (cargo_condition)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`,
);
}
}

View File

@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Contract paper for empty container import.
*
* - contracts.cargo_condition mirrors bookings.cargo_condition, so a general
* contract can commit to moving bare equipment.
* - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer
* resolves for those contracts. It carries no customs variant: an empty box
* has no declaration to clear, the same reason intercity is unsuffixed.
*/
const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const;
export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface {
name = 'EmptyContainerContractTemplate3910000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK (
cargo_condition IN ('LADEN', 'EMPTY')
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
)
`);
for (const code of SEEDED_CODES) {
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
if (!seed) throw new Error(`Missing contract template default for ${code}`);
await queryRunner.query(
`INSERT INTO freight.contract_templates
(id, code, name, description, document_title, whereas_clauses, articles,
is_active, is_system, created_at, updated_at)
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
true, true, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.contract_templates
WHERE code = $1::varchar AND deleted_at IS NULL
)`,
[
seed.code,
seed.name,
seed.description,
seed.documentTitle,
JSON.stringify(seed.whereasClauses),
JSON.stringify(
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
),
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.contract_templates WHERE code = ANY($1::varchar[]) AND is_system = true`,
[[...SEEDED_CODES]],
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`,
);
}
}