mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
feat: implement shipping line bookings management
- Add ShippingLineBookingsPage for listing and managing shipping line bookings. - Create ShippingLineDocumentsModal for document uploads related to bookings. - Introduce ShippingLineInitiateModal for initiating new shipping line bookings. - Implement booking document state management with booking-doc-state utility. - Add shipping line bookings service for API interactions. - Update index to export new components and services. - Enhance types for freight to include shipping line credits.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shipping lines book rail capacity directly, without a contract.
|
||||
*
|
||||
* A booking has always been owned by `company_id` (a customer `companies` row),
|
||||
* but a shipping line is a `shipping_line_companies` row and deliberately NOT a
|
||||
* company — it carries no TIN, licence or operational profiles. So it gets its
|
||||
* own nullable owner column rather than a synthetic company row.
|
||||
*
|
||||
* Exactly one of the two is set: `company_id` for a customer booking,
|
||||
* `shipping_line_company_id` for a shipping-line one. Existing rows keep
|
||||
* `company_id` and a NULL `shipping_line_company_id`, so nothing needs
|
||||
* backfilling and every customer query filtering on `company_id` behaves
|
||||
* exactly as before. Government bookings already bill to a seeded government
|
||||
* company, so they satisfy the CHECK unchanged.
|
||||
*
|
||||
* NOTE: not to be confused with the existing `bookings.shipping_line_id`, which
|
||||
* is cargo metadata naming the carrier line that moves the goods
|
||||
* (`freight.shipping_lines`, reference data). This column points at
|
||||
* `freight.shipping_line_companies` — the portal account — and is unrelated.
|
||||
*/
|
||||
export class BookingShippingLine3450000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id
|
||||
ON freight.bookings (shipping_line_company_id)
|
||||
`);
|
||||
|
||||
// `company_id` / `company_profile_id` are NOT NULL and point at the customer
|
||||
// tables, so a shipping-line booking could not be inserted at all. Relax
|
||||
// them to nullable; their foreign keys are left in place and keep validating
|
||||
// every non-NULL value, so a customer booking is constrained exactly as
|
||||
// before. The CHECK below is what now guarantees an owner is present.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL
|
||||
`);
|
||||
|
||||
// Route and service are inherited from the contract on a customer booking.
|
||||
// A shipping line initiates before any of that is known — the bare booking
|
||||
// exists only to hang documents off — so these are relaxed too and filled
|
||||
// in when the booking is completed. Existing rows all have values, and the
|
||||
// customer paths still always set them.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL
|
||||
`);
|
||||
|
||||
// No FK: kept consistent with how the column is populated at the service
|
||||
// layer, and avoids a lock on shipping_line_companies during deploy.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT chk_bookings_single_owner
|
||||
CHECK (
|
||||
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
|
||||
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS chk_bookings_single_owner
|
||||
`);
|
||||
|
||||
// Only reinstate NOT NULL if no shipping-line booking exists; those rows
|
||||
// have a NULL company_id by design and would make the ALTER fail. Leaving
|
||||
// the columns nullable is the safe outcome — the constraint is additive.
|
||||
const [{ count }] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS count FROM freight.bookings
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`)) as Array<{ count: number }>;
|
||||
|
||||
if (count === 0) {
|
||||
for (const column of [
|
||||
"company_id",
|
||||
"company_profile_id",
|
||||
"origin_yard_id",
|
||||
"destination_yard_id",
|
||||
"service_type_id",
|
||||
"freight_type",
|
||||
]) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Shipping lines consume services before paying for them.
|
||||
*
|
||||
* A shipping line books rail capacity and the booking proceeds with no payment
|
||||
* gate at all — unlike a customer booking, which cannot advance until its
|
||||
* PREPAID invoice settles. What the line owes is instead recorded here as a
|
||||
* credit: one row per booking, priced once and never recalculated. Finance
|
||||
* later selects a batch of unbilled credits, generates a single invoice for
|
||||
* them, and the line pays that invoice through the normal CBE flow. When the
|
||||
* invoice settles, its credits are marked paid and stop counting as debt.
|
||||
*
|
||||
* This is deliberately NOT a wallet or a stored balance. There is no money in
|
||||
* the system to draw down: a credit is a debt the line already incurred, so
|
||||
* the outstanding figure is always derived (`SUM(amount) WHERE status <>
|
||||
* 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync.
|
||||
*
|
||||
* `invoices.company_id` / `company_profile_id` are relaxed to nullable for the
|
||||
* same reason `bookings` was in {@link BookingShippingLine3450000000000}: a
|
||||
* shipping line is not a `companies` row and never will be, so an invoice
|
||||
* billed to one has no customer to point at. Both FKs stay in place and keep
|
||||
* validating every non-NULL value, so a customer invoice is constrained
|
||||
* exactly as before; the CHECK below is what now guarantees a payer exists.
|
||||
*/
|
||||
export class ShippingLineCredits3460000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── Invoices: allow a shipping-line payer ────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id
|
||||
ON freight.invoices (shipping_line_company_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL
|
||||
`);
|
||||
|
||||
// Exactly one payer. Mirrors chk_bookings_single_owner so the two tables
|
||||
// answer "who owes this?" the same way. Existing rows all have company_id
|
||||
// and a NULL shipping_line_company_id, so nothing needs backfilling.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
ADD CONSTRAINT chk_invoices_single_payer
|
||||
CHECK (
|
||||
(company_id IS NOT NULL AND shipping_line_company_id IS NULL)
|
||||
OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
|
||||
// ── The credit ledger ────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM (
|
||||
'UNBILLED', 'BILLED', 'PAID', 'CANCELLED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.shipping_line_credits (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
shipping_line_company_id uuid NOT NULL,
|
||||
booking_id uuid NOT NULL,
|
||||
amount numeric(14,2) NOT NULL,
|
||||
currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL,
|
||||
status freight.shipping_line_credits_status_enum
|
||||
DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL,
|
||||
description character varying(255),
|
||||
invoice_id uuid,
|
||||
billed_at timestamp with time zone,
|
||||
paid_at timestamp with time zone,
|
||||
cancelled_at timestamp with time zone,
|
||||
cancellation_reason character varying(255),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted_at timestamp with time zone,
|
||||
CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id),
|
||||
CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0),
|
||||
-- The state machine, enforced in the DB rather than trusted to the
|
||||
-- service: an UNBILLED credit has no invoice, and anything past
|
||||
-- UNBILLED must name the invoice it was billed on. Without this a
|
||||
-- half-applied batch could leave BILLED rows with a NULL invoice_id
|
||||
-- and silently vanish from both the unbilled list and the invoice.
|
||||
CONSTRAINT chk_shipping_line_credits_invoice_link CHECK (
|
||||
(status = 'UNBILLED' AND invoice_id IS NULL)
|
||||
OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL)
|
||||
OR status = 'CANCELLED'
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_shipping_line
|
||||
FOREIGN KEY (shipping_line_company_id)
|
||||
REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_booking
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id) ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
// SET NULL rather than CASCADE: deleting an invoice must never delete the
|
||||
// record of what was owed. The row would then violate the link CHECK, so a
|
||||
// credit whose invoice is removed has to be walked back to UNBILLED
|
||||
// explicitly — which is the correct, visible outcome.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.shipping_line_credits
|
||||
ADD CONSTRAINT fk_shipping_line_credits_invoice
|
||||
FOREIGN KEY (invoice_id)
|
||||
REFERENCES freight.invoices(id) ON DELETE SET NULL
|
||||
`);
|
||||
|
||||
// One live credit per booking. Partial so a soft-deleted or cancelled row
|
||||
// does not block re-pricing a booking that was voided and rebooked.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking
|
||||
ON freight.shipping_line_credits (booking_id)
|
||||
WHERE deleted_at IS NULL AND status <> 'CANCELLED'
|
||||
`);
|
||||
|
||||
// Drives the two hot reads: finance's unbilled worklist per line, and the
|
||||
// outstanding total on the shipping-line detail page.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status
|
||||
ON freight.shipping_line_credits (shipping_line_company_id, status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id
|
||||
ON freight.shipping_line_credits (invoice_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP TABLE IF EXISTS freight.shipping_line_credits
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP CONSTRAINT IF EXISTS chk_invoices_single_payer
|
||||
`);
|
||||
|
||||
// Only reinstate NOT NULL if no shipping-line invoice exists; those rows
|
||||
// have a NULL company_id by design and would make the ALTER fail. Leaving
|
||||
// the columns nullable is the safe outcome — the constraint is additive.
|
||||
const [{ count }] = (await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS count FROM freight.invoices
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`)) as Array<{ count: number }>;
|
||||
|
||||
if (count === 0) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.invoices
|
||||
DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-shipping-line rates.
|
||||
*
|
||||
* A shipping line books rail capacity directly (see BookingShippingLine3450000000000)
|
||||
* and negotiates its own prices, so the rate table gains an owner column:
|
||||
* `shipping_line_company_id` NULL = the standard rate every customer pays,
|
||||
* NOT NULL = a rate that only that line's bookings resolve.
|
||||
*
|
||||
* Points at `freight.shipping_line_companies` (the portal account that owns the
|
||||
* booking), NOT `freight.shipping_lines` — the latter is carrier reference data
|
||||
* naming who physically moves the goods, and the existing SHIPPING_LINE trigger
|
||||
* already keys off it. Both stay independent.
|
||||
*
|
||||
* Line rates OVERRIDE rather than stack: a booking owned by a line prices off
|
||||
* that line's rate for the lane, and is hard-blocked when none exists (the
|
||||
* standard rate is deliberately not a fallback — see RuleEngineService).
|
||||
*
|
||||
* Every existing row keeps a NULL owner, so nothing needs backfilling and the
|
||||
* standard-rate lookups behave exactly as before.
|
||||
*/
|
||||
export class ShippingLineRates3470000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "FK_rates_shipping_line_company"
|
||||
FOREIGN KEY (shipping_line_company_id)
|
||||
REFERENCES freight.shipping_line_companies (id)
|
||||
ON DELETE RESTRICT
|
||||
`);
|
||||
|
||||
// Rate resolution always filters by owner, so the lookups this column
|
||||
// participates in are (owner, lane) — indexed together with rate_type,
|
||||
// which every lookup also pins.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id
|
||||
ON freight.rates (shipping_line_company_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane
|
||||
ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id)
|
||||
WHERE shipping_line_company_id IS NOT NULL
|
||||
`);
|
||||
|
||||
// A shipping line sells import freight only — the export leg is contracted
|
||||
// through the customer, not the carrier. Enforced here so a line rate can
|
||||
// never be filed against an export lane regardless of which API path wrote
|
||||
// it. Surcharges carry no direction and are unaffected.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
shipping_line_company_id IS NULL OR
|
||||
trade_direction IS NULL OR trade_direction = 'IMPORT'
|
||||
)
|
||||
`);
|
||||
|
||||
// The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo
|
||||
// rate collides with the standard rate for the same lane — same rate_type,
|
||||
// same scope, same unit — and the insert fails on UQ_rates_pattern. NULL
|
||||
// (the standard rate) collapses to the zero uuid like every other nullable
|
||||
// scope column, so existing rows keep their current uniqueness exactly.
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||
rate_type,
|
||||
COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''::character varying),
|
||||
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
rate_unit,
|
||||
COALESCE(min_km, '-1'::numeric)
|
||||
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Restore the pre-owner pattern index (as left by LastMileRateBands).
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree (
|
||||
rate_type,
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, ''::character varying),
|
||||
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
rate_unit,
|
||||
COALESCE(min_km, '-1'::numeric)
|
||||
) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text))
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only"
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user