contrat,booking,global logestic

This commit is contained in:
Marshal
2026-06-26 23:24:48 +00:00
parent f931342f31
commit 01d53c218c
105 changed files with 19573 additions and 909 deletions

View File

@@ -0,0 +1,357 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* ContractBooking 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;`);
}
}

View File

@@ -0,0 +1,175 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Data backfill for the contractbooking 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-%';`);
}
}

View File

@@ -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.
}
}