mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Contract–Booking separation (additive phase). Introduces a first-class
|
||||
* `freight.contracts` aggregate that owns the legal/commercial agreement (scope
|
||||
* + unit rates, no quantities) and spawns shipment `bookings` via `contract_id`.
|
||||
*
|
||||
* Purely additive: no legacy columns are dropped here. The data backfill and
|
||||
* legacy-column removal happen in a later cutover migration.
|
||||
*
|
||||
* See docs/new-doc.md §5.
|
||||
*/
|
||||
export class CreateContracts1822000000000 implements MigrationInterface {
|
||||
name = 'CreateContracts1822000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── contracts ───────────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contracts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reference VARCHAR(64) NOT NULL UNIQUE,
|
||||
|
||||
company_id UUID,
|
||||
company_profile_id UUID,
|
||||
is_government BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
government_institution VARCHAR(255),
|
||||
|
||||
contract_kind VARCHAR(20) NOT NULL,
|
||||
renewal_of_id UUID REFERENCES freight.contracts(id),
|
||||
trade_direction VARCHAR(10) NOT NULL,
|
||||
freight_type VARCHAR(20) NOT NULL,
|
||||
|
||||
service_type_id UUID NOT NULL,
|
||||
payment_currency VARCHAR(5) NOT NULL,
|
||||
customs_clearing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
customs_clearing_agent VARCHAR(200),
|
||||
equipment_return VARCHAR(20),
|
||||
|
||||
first_mile_pickup_address TEXT,
|
||||
first_mile_pickup_lat NUMERIC(10,7),
|
||||
first_mile_pickup_lng NUMERIC(10,7),
|
||||
last_mile_delivery_address TEXT,
|
||||
last_mile_delivery_lat NUMERIC(10,7),
|
||||
last_mile_delivery_lng NUMERIC(10,7),
|
||||
|
||||
is_hazardous BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_reefer BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
estimated_shipment_date TIMESTAMPTZ,
|
||||
contract_validity_days INT,
|
||||
contract_valid_from TIMESTAMPTZ,
|
||||
contract_valid_until TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
|
||||
clearance_status VARCHAR(40) NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
clearance_cycle_number INT NOT NULL DEFAULT 0,
|
||||
|
||||
pricing_breakdown JSONB,
|
||||
pricing_display_mode VARCHAR(20) DEFAULT 'UNIT_RATES',
|
||||
|
||||
contract_type VARCHAR(20),
|
||||
contract_template_key VARCHAR(128),
|
||||
contract_generated_at TIMESTAMPTZ,
|
||||
contract_summary TEXT,
|
||||
version_number INT NOT NULL DEFAULT 1,
|
||||
financial_terms JSONB,
|
||||
|
||||
approved_by_staff_id UUID,
|
||||
approved_by_staff_at TIMESTAMPTZ,
|
||||
signed_by_director_id UUID,
|
||||
signed_by_director_at TIMESTAMPTZ,
|
||||
signed_by_ceo_id UUID,
|
||||
signed_by_ceo_at TIMESTAMPTZ,
|
||||
customer_signed_at TIMESTAMPTZ,
|
||||
fully_executed_at TIMESTAMPTZ,
|
||||
locked_at TIMESTAMPTZ,
|
||||
|
||||
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_contracts_company ON freight.contracts(company_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_status ON freight.contracts(status);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_kind ON freight.contracts(contract_kind);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_valid_until ON freight.contracts(contract_valid_until);`);
|
||||
|
||||
// ── contract_routes ──────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
origin_yard_id UUID NOT NULL,
|
||||
destination_yard_id UUID NOT NULL,
|
||||
km NUMERIC(10,2),
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_route UNIQUE (contract_id, origin_yard_id, destination_yard_id)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_routes_contract ON freight.contract_routes(contract_id);`);
|
||||
|
||||
// ── contract_cargo_scope ─────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_cargo_scope (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
container_size VARCHAR(10),
|
||||
cargo_type_id UUID,
|
||||
cargo_free_text VARCHAR(200),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_container_size UNIQUE NULLS NOT DISTINCT (contract_id, container_size)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_cargo_scope_contract ON freight.contract_cargo_scope(contract_id);`);
|
||||
|
||||
// ── contract_signatures ──────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
signer_display_name VARCHAR(255) NOT NULL,
|
||||
signature_file_id UUID,
|
||||
consent_text TEXT,
|
||||
signed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
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_contract_signatures_contract ON freight.contract_signatures(contract_id);`);
|
||||
|
||||
// ── contract_approval_steps ──────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_approval_steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
step_order SMALLINT NOT NULL DEFAULT 0,
|
||||
required_role VARCHAR(40) NOT NULL,
|
||||
blocks_role VARCHAR(40),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
acted_by_staff_id UUID,
|
||||
acted_at TIMESTAMPTZ,
|
||||
note TEXT,
|
||||
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_contract_approval_steps_contract ON freight.contract_approval_steps(contract_id);`);
|
||||
|
||||
// ── contract_rate_snapshots ──────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_rate_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
rate_id UUID,
|
||||
rate_code VARCHAR(64) NOT NULL,
|
||||
description VARCHAR(255),
|
||||
unit_price NUMERIC(14,2) NOT NULL,
|
||||
unit_of_measure VARCHAR(32) NOT NULL,
|
||||
currency VARCHAR(5) NOT NULL,
|
||||
container_size VARCHAR(10),
|
||||
is_surcharge BOOLEAN DEFAULT FALSE,
|
||||
conditional_on VARCHAR(32),
|
||||
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_contract_rate_snapshots_contract ON freight.contract_rate_snapshots(contract_id);`);
|
||||
|
||||
// ── contract_review_notes ────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_review_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
note_type VARCHAR(40) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
author_role VARCHAR(20),
|
||||
author_user_id UUID,
|
||||
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_contract_review_notes_contract ON freight.contract_review_notes(contract_id);`);
|
||||
|
||||
// ── contract_clearance_cycles ────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_clearance_cycles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
cycle_number INT NOT NULL,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'AWAITING_DOCUMENTS',
|
||||
booking_id UUID,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
clearance_ready_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_clearance_cycle UNIQUE (contract_id, cycle_number)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_clearance_cycles_contract ON freight.contract_clearance_cycles(contract_id);`);
|
||||
|
||||
// ── contract_document_review (pre-booking clearance, Path B) ─────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.contract_document_review (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
|
||||
setting_code VARCHAR(128) NOT NULL,
|
||||
file_key VARCHAR(128) NOT NULL,
|
||||
file_record_id UUID,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
note TEXT,
|
||||
uploaded_by_role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER',
|
||||
reviewed_by_staff_id UUID,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_contract_document_review_doc
|
||||
UNIQUE NULLS NOT DISTINCT (contract_id, clearance_cycle_id, setting_code, file_key)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_contract ON freight.contract_document_review(contract_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_status ON freight.contract_document_review(status);`);
|
||||
|
||||
// ── clearance_milestones (GL tracking) ───────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.clearance_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
contract_id UUID REFERENCES freight.contracts(id) ON DELETE CASCADE,
|
||||
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
|
||||
milestone_code VARCHAR(64) NOT NULL,
|
||||
milestone_label VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
owner_region VARCHAR(5),
|
||||
triggered_by_doc BOOLEAN DEFAULT FALSE,
|
||||
triggered_at TIMESTAMPTZ,
|
||||
triggered_by_user_id UUID,
|
||||
note TEXT,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
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_clearance_milestones_booking ON freight.clearance_milestones(booking_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_contract ON freight.clearance_milestones(contract_id);`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_region ON freight.clearance_milestones(owner_region, status);`);
|
||||
// booking-scoped and contract-cycle-scoped uniqueness for milestone codes
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_booking
|
||||
ON freight.clearance_milestones(booking_id, milestone_code) WHERE booking_id IS NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_cycle
|
||||
ON freight.clearance_milestones(clearance_cycle_id, milestone_code) WHERE clearance_cycle_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
// ── booking_container_units (per-unit container detail) ──────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_container_units (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_container_id UUID NOT NULL REFERENCES freight.booking_container(id) ON DELETE CASCADE,
|
||||
container_number VARCHAR(64) NOT NULL,
|
||||
seal_number VARCHAR(64),
|
||||
vgm_tons NUMERIC(10,3) NOT NULL,
|
||||
is_hazardous BOOLEAN DEFAULT FALSE,
|
||||
is_reefer BOOLEAN DEFAULT FALSE,
|
||||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_booking_container_unit_number UNIQUE (booking_container_id, container_number)
|
||||
);
|
||||
`);
|
||||
|
||||
// ── ALTER bookings ───────────────────────────────────────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_route_id UUID REFERENCES freight.contract_routes(id);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_role VARCHAR(20) DEFAULT 'CUSTOMER';`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_user_id UUID;`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_contract ON freight.bookings(contract_id);`);
|
||||
// One active booking per ONE_TIME contract. Postgres forbids a subquery in an
|
||||
// index predicate, so we denormalize the contract kind onto the booking and
|
||||
// predicate on that. The column is stamped at booking creation from the
|
||||
// contract; the app layer (ContractBookingService) is the primary guard and
|
||||
// this index is the backstop.
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_kind VARCHAR(20);`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_one_active_booking_per_one_time_contract
|
||||
ON freight.bookings (contract_id)
|
||||
WHERE status NOT IN ('EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED')
|
||||
AND contract_id IS NOT NULL
|
||||
AND contract_kind = 'ONE_TIME';
|
||||
`);
|
||||
|
||||
// ── ALTER booking_container ──────────────────────────────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS container_size VARCHAR(10);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS hazardous_quantity SMALLINT DEFAULT 0;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS reefer_quantity SMALLINT DEFAULT 0;`);
|
||||
|
||||
// ── ALTER booking_document_review (denormalized contract link) ───────────
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_document_review ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
|
||||
|
||||
// ── Extend file_upload_fields with phased GL metadata ────────────────────
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS phase VARCHAR(40);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS owner_region VARCHAR(5);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS trade_direction VARCHAR(10);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS triggers_milestone_code VARCHAR(64);`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS triggers_milestone_code;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS trade_direction;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS owner_region;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS phase;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_document_review DROP COLUMN IF EXISTS contract_id;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS reefer_quantity;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS hazardous_quantity;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS container_size;`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_contract;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_kind;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_user_id;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_role;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_route_id;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_id;`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_container_units;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_milestones;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_document_review;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_clearance_cycles;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_review_notes;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_rate_snapshots;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_approval_steps;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_signatures;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_cargo_scope;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_routes;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contracts;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Data backfill for the contract–booking separation (docs/new-doc.md §17).
|
||||
*
|
||||
* For every legacy `booking_type = 'GENERAL_CONTRACT'` booking we synthesise a
|
||||
* `freight.contracts` row from its contract-phase columns, copy its routes
|
||||
* (contract_route_lines → contract_routes, dropping quantity), and point the
|
||||
* contract + every child shipment booking (linked via booking_orders) at it.
|
||||
*
|
||||
* Per §19 item 1, historical ONE_TIME bookings that went through the full
|
||||
* contract flow get a contract parent inserted and `contract_id` set on the same
|
||||
* booking row (no row split).
|
||||
*
|
||||
* Idempotent: skips bookings that already have `contract_id` set, and matches a
|
||||
* synthesised contract by a deterministic `CTR-<bookingId>` reference.
|
||||
*/
|
||||
export class BackfillContractsFromBookings1823000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'BackfillContractsFromBookings1823000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. One contract per GENERAL_CONTRACT booking, carrying the contract-phase
|
||||
// columns. Reference is derived from the source booking id so re-runs are
|
||||
// idempotent (ON CONFLICT DO NOTHING on the unique reference).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contracts (
|
||||
reference, company_id, company_profile_id, is_government, government_institution,
|
||||
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
|
||||
customs_clearing_enabled, customs_clearing_agent, equipment_return,
|
||||
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
|
||||
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
|
||||
is_hazardous, is_reefer, estimated_shipment_date,
|
||||
contract_validity_days, contract_valid_from, contract_valid_until, expires_at,
|
||||
status, clearance_status, clearance_cycle_number,
|
||||
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
|
||||
contract_summary, version_number,
|
||||
approved_by_staff_id, approved_by_staff_at,
|
||||
signed_by_director_id, signed_by_director_at,
|
||||
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
|
||||
'GENERAL', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
|
||||
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
|
||||
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
|
||||
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
|
||||
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
|
||||
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until, b.expires_at,
|
||||
CASE
|
||||
WHEN b.status IN ('CONTRACT_ACTIVE') THEN 'CONTRACT_ACTIVE'
|
||||
WHEN b.status IN ('CONTRACT_CLOSED') THEN 'CONTRACT_CLOSED'
|
||||
WHEN b.status IN ('EXPIRED') THEN 'EXPIRED'
|
||||
WHEN b.status IN ('CANCELLED') THEN 'CANCELLED'
|
||||
WHEN b.status IN ('REJECTED') THEN 'REJECTED'
|
||||
ELSE 'CONTRACT_ACTIVE'
|
||||
END,
|
||||
CASE WHEN b.customs_clearing_enabled THEN 'NOT_APPLICABLE' ELSE 'NOT_APPLICABLE' END,
|
||||
0,
|
||||
b.pricing_breakdown,
|
||||
b.contract_type, b.contract_template_key, b.contract_generated_at,
|
||||
b.contract_summary, COALESCE(b.version_number, 1),
|
||||
b.approved_by_staff_id, b.approved_by_staff_at,
|
||||
b.signed_by_director_id, b.signed_by_director_at,
|
||||
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
|
||||
b.created_at, b.updated_at
|
||||
FROM freight.bookings b
|
||||
WHERE b.booking_type = 'GENERAL_CONTRACT'
|
||||
ON CONFLICT (reference) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 2. Copy each general contract's route lines into contract_routes (no qty).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, km, sort_order, created_at, updated_at)
|
||||
SELECT c.id, crl.origin_yard_id, crl.destination_yard_id, crl.km, 0, now(), now()
|
||||
FROM freight.contract_route_lines crl
|
||||
JOIN freight.contracts c ON c.reference = 'CTR-' || crl.contract_booking_id::text
|
||||
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
|
||||
`);
|
||||
|
||||
// 3. Point the general-contract booking itself at its new contract, and stamp
|
||||
// the denormalized contract_kind for the active-booking index.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
|
||||
FROM freight.contracts c
|
||||
WHERE c.reference = 'CTR-' || b.id::text
|
||||
AND b.booking_type = 'GENERAL_CONTRACT'
|
||||
AND b.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 4. Point each child shipment booking (spawned via booking_orders) at the
|
||||
// same contract as its parent general contract.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings child
|
||||
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
|
||||
FROM freight.booking_orders bo
|
||||
JOIN freight.contracts c ON c.reference = 'CTR-' || bo.contract_booking_id::text
|
||||
WHERE child.id = bo.booking_id
|
||||
AND child.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 5. Historical ONE_TIME bookings that completed the contract flow: synthesise
|
||||
// a contract parent and point the same booking row at it (no row split).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contracts (
|
||||
reference, company_id, company_profile_id, is_government, government_institution,
|
||||
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
|
||||
customs_clearing_enabled, customs_clearing_agent, equipment_return,
|
||||
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
|
||||
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
|
||||
is_hazardous, is_reefer, estimated_shipment_date,
|
||||
contract_validity_days, contract_valid_from, contract_valid_until,
|
||||
status, clearance_status, clearance_cycle_number,
|
||||
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
|
||||
contract_summary, version_number,
|
||||
approved_by_staff_id, approved_by_staff_at,
|
||||
signed_by_director_id, signed_by_director_at,
|
||||
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
|
||||
'ONE_TIME', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
|
||||
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
|
||||
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
|
||||
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
|
||||
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
|
||||
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until,
|
||||
'FULLY_EXECUTED', 'NOT_APPLICABLE', 0,
|
||||
b.pricing_breakdown, b.contract_type, b.contract_template_key, b.contract_generated_at,
|
||||
b.contract_summary, COALESCE(b.version_number, 1),
|
||||
b.approved_by_staff_id, b.approved_by_staff_at,
|
||||
b.signed_by_director_id, b.signed_by_director_at,
|
||||
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
|
||||
b.created_at, b.updated_at
|
||||
FROM freight.bookings b
|
||||
WHERE COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
|
||||
AND b.contract_id IS NULL
|
||||
AND b.contract_generated_at IS NOT NULL
|
||||
ON CONFLICT (reference) DO NOTHING;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET contract_id = c.id, contract_kind = 'ONE_TIME', created_by_role = 'CUSTOMER'
|
||||
FROM freight.contracts c
|
||||
WHERE c.reference = 'CTR-' || b.id::text
|
||||
AND COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
|
||||
AND b.contract_id IS NULL;
|
||||
`);
|
||||
|
||||
// 6. Build a single route per ONE_TIME contract from the booking's own
|
||||
// origin/destination (general contracts already got their routes in step 2).
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, sort_order, created_at, updated_at)
|
||||
SELECT c.id, b.origin_yard_id, b.destination_yard_id, 0, now(), now()
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts c ON c.id = b.contract_id AND c.contract_kind = 'ONE_TIME'
|
||||
WHERE b.origin_yard_id IS NOT NULL AND b.destination_yard_id IS NOT NULL
|
||||
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Unlink bookings and drop the synthesised contracts (and their cascaded routes).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET contract_id = NULL, contract_route_id = NULL
|
||||
WHERE contract_id IN (SELECT id FROM freight.contracts WHERE reference LIKE 'CTR-%');
|
||||
`);
|
||||
await queryRunner.query(`DELETE FROM freight.contracts WHERE reference LIKE 'CTR-%';`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Cutover cleanup (docs/new-doc.md §17 Phase 4). Runs AFTER the backfill
|
||||
* (1823…) so every legacy general contract + drawdown already lives in the
|
||||
* `contracts` aggregate.
|
||||
*
|
||||
* Drops the now-unused booking-as-contract artifacts:
|
||||
* - `bookings.booking_type` (every booking is a real shipment now)
|
||||
* - `bookings.previous_contract_id` (renewal lives on `contracts.renewal_of_id`)
|
||||
* - the `booking_orders` / `booking_order_lines` drawdown ledger
|
||||
* - `contract_route_lines` (superseded by `contract_routes`)
|
||||
*
|
||||
* The shipment/payment/scheduling/allocation columns on `bookings` are kept —
|
||||
* the operational pipeline is unchanged.
|
||||
*/
|
||||
export class DropLegacyContractTables1824000000000 implements MigrationInterface {
|
||||
name = 'DropLegacyContractTables1824000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// booking_order_lines references booking_orders → drop child first.
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_order_lines CASCADE;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_orders CASCADE;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_route_lines CASCADE;`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS previous_contract_id;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Re-add the dropped columns (data is not restored — this is a one-way cutover).
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) DEFAULT 'ONE_TIME';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS previous_contract_id UUID;`,
|
||||
);
|
||||
// The legacy ledger/route tables are intentionally NOT recreated here; restore
|
||||
// from a backup if a rollback past the cutover is ever required.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user