mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
- 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.
202 lines
8.6 KiB
TypeScript
202 lines
8.6 KiB
TypeScript
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
|
|
`);
|
|
}
|
|
}
|