merge conflict resolved

This commit is contained in:
marshal
2026-09-02 22:35:18 +00:00
376 changed files with 26459 additions and 2649 deletions

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Attaches an eTrade business licence to each operational profile.
*
* A TIN routinely holds a dozen or more licences, split by activity ("Export
* trade in coffee", "Freight Forwarders"), and until now the company picked one
* for the whole record — every role shared it. Each profile now names the
* business it actually operates as.
*
* Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName,
* activity, renewedTo) rather than a bare licence number, so the portal and the
* backoffice can show which business is attached without an eTrade round-trip —
* eTrade is slow, serves a broken TLS chain, and is regularly down.
*
* Nullable: existing profiles have none until the customer attaches one, and a
* co-operative or investor-licence company has no eTrade record at all.
* Deliberately NOT unique — one business can back several profiles.
*/
export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface {
name = 'CompanyProfileEtradeBusiness3760000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
ADD COLUMN IF NOT EXISTS etrade_business jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
DROP COLUMN IF EXISTS etrade_business
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Empties backfilled into the yard belong to a company that may not be a
* registered customer yet, so `customer_id` cannot hold it. `company_name` is
* the typed fallback, and the display label when the customer IS registered.
*/
export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface {
name = 'EmptyContainerReturnCompanyName3790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Backlog registration of full containers that were already sitting in a yard
* before the system knew about them. Such a row carries a true, backdated
* `arrived_at` for the record but accrues NO storage or demurrage — the
* operator decided these are not billable retroactively — so the flag exists
* to keep the fee engine off them.
*
* `company_id` / `company_name` carry the owner, since a backlog row has no
* booking to inherit one from. The name is free text for a company that is not
* a registered customer yet.
*/
export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface {
name = 'WarehouseInventoryBacklogRegistration3800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS company_id uuid,
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS backlog_registration,
DROP COLUMN IF EXISTS company_id,
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seed `edr_freight_app:warehouse_zones:delete` — the zone counterpart of the
* warehouse and yard delete permissions, which already exist.
*
* `ROLE_PERMISSION_PRESETS` spreads `Object.values(FREIGHT_PERMS.warehouseZones)`
* into the warehouse positions, so the moment the key is added to the registry
* `FreightPositionsSeeder.loadPermissionIds` resolves it against `iam.permissions`
* at boot — and throws `missing_permissions:<key>` if the row is absent. The
* catalog is otherwise written by `EdrOrgSeeder`, which skips itself unless
* `SEED_EDR_ORG` is set, so a migration is the only path that runs everywhere.
*
* Idempotent on `key`; keeps the registry's fixed uuid so every environment
* lands on the same id. Skips silently when the freight application row is
* absent, since there is nothing to attach to.
*/
export class WarehouseZoneDeletePermission3810000000000 implements MigrationInterface {
private static readonly KEY = 'edr_freight_app:warehouse_zones:delete';
private static readonly ID = 'f1c00001-0001-4000-8000-000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`INSERT INTO iam.permissions (id, key, name, application_id)
SELECT $2::uuid,
$1::varchar,
'{"am": "Delete warehouse zone", "en": "Delete warehouse zone"}'::jsonb,
a.id
FROM iam.application a
WHERE a.key = 'edr_freight_app'
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
[WarehouseZoneDeletePermission3810000000000.KEY, WarehouseZoneDeletePermission3810000000000.ID],
);
}
/**
* Grants go first, or the delete trips the position/role permission foreign
* keys — a half-removed permission is worse than one left in place.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM iam.position_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[WarehouseZoneDeletePermission3810000000000.KEY],
);
await queryRunner.query(
`DELETE FROM iam.role_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[WarehouseZoneDeletePermission3810000000000.KEY],
);
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
WarehouseZoneDeletePermission3810000000000.KEY,
]);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* `freight.warehouses.freight_type` — CONTAINER or BULK, or null for a site
* that takes both.
*
* Nullable with no backfill on purpose: every existing warehouse predates the
* field and is unrestricted today, so writing a value would narrow live
* allocation behind the operator's back.
*/
export class WarehouseFreightType3820000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouses ADD COLUMN IF NOT EXISTS freight_type varchar(16)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouses DROP COLUMN IF EXISTS freight_type`);
}
}

View File

@@ -0,0 +1,128 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Physical container positions below the zone: a stack is the ground footprint,
* a slot is one level in it. Adds `stack_id` / `slot_id` to warehouse inventory.
*
* Everything is additive and nullable. Existing inventory keeps warehouse /
* yard / zone as its only location and stays valid — nothing is backfilled,
* because no one can know where a box already in the yard is actually stacked.
*
* Occupancy is not stored on the slot. `uq_warehouse_inventory_active_slot`
* makes the inventory row the single source of truth: one live placement per
* slot, enforced by Postgres. Its status list must stay in step with
* `SLOT_OCCUPYING_STATUSES` in warehouse-inventory.entity.ts.
*/
export class WarehouseZoneStacksSlots3830000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_zone_stacks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
zone_id uuid NOT NULL REFERENCES freight.warehouse_zones(id) ON DELETE CASCADE,
code varchar(40) NOT NULL,
name varchar(160),
"row" varchar(20),
bay varchar(20),
"position" varchar(20),
max_stack_height int NOT NULL DEFAULT 3,
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_warehouse_zone_stacks_height CHECK (max_stack_height >= 1)
)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_zone ON freight.warehouse_zone_stacks (zone_id)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_status ON freight.warehouse_zone_stacks (status)`,
);
// Partial: a soft-deleted stack must not block reusing its code.
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_stacks_zone_code
ON freight.warehouse_zone_stacks (zone_id, code) WHERE deleted_at IS NULL`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_zone_slots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stack_id uuid NOT NULL REFERENCES freight.warehouse_zone_stacks(id) ON DELETE CASCADE,
level int NOT NULL,
status varchar(16) NOT NULL DEFAULT 'AVAILABLE',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_warehouse_zone_slots_level CHECK (level >= 1)
)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_slots_stack ON freight.warehouse_zone_slots (stack_id, level)`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_slots_stack_level
ON freight.warehouse_zone_slots (stack_id, level) WHERE deleted_at IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS stack_id uuid`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS slot_id uuid`,
);
// Named FKs added defensively — ADD CONSTRAINT has no IF NOT EXISTS.
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warehouse_inventory
ADD CONSTRAINT fk_warehouse_inventory_stack
FOREIGN KEY (stack_id) REFERENCES freight.warehouse_zone_stacks(id);
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warehouse_inventory
ADD CONSTRAINT fk_warehouse_inventory_slot
FOREIGN KEY (slot_id) REFERENCES freight.warehouse_zone_slots(id);
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_stack ON freight.warehouse_inventory (stack_id)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_slot ON freight.warehouse_inventory (slot_id)`,
);
// One live container per slot. Statuses past the yard gate (LOADED,
// DISPATCHED, DELIVERED, UNLOADED_AT_DJIBOUTI_PORT) free the position
// without any exit path having to clear the column.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_inventory_active_slot
ON freight.warehouse_inventory (slot_id)
WHERE deleted_at IS NULL
AND slot_id IS NOT NULL
AND status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP')
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_warehouse_inventory_active_slot`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_slot`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_stack`);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_slot`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_stack`,
);
await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS slot_id`);
await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS stack_id`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_slots`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_stacks`);
}
}

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Customer-initiated empty container return, for a booking that did NOT buy
* the return service up front. The customer names the containers coming back,
* operations approves and prices it off the contract's WITH_RETURN rate, the
* customer pays that invoice and then books the date and truck. The empty
* itself is still recorded through `empty_container_returns` when the truck
* actually arrives — this table only carries the request up to that point.
*/
export class EmptyReturnRequests3840000000000 implements MigrationInterface {
name = 'EmptyReturnRequests3840000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.empty_return_requests (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
booking_id uuid NOT NULL,
company_id uuid,
status varchar(30) NOT NULL DEFAULT 'SUBMITTED',
container_numbers text[] NOT NULL DEFAULT '{}',
container_count smallint NOT NULL DEFAULT 0,
quoted_unit_amount numeric(14,2),
quoted_total_amount numeric(14,2),
currency varchar(8),
invoice_id uuid,
paid_at timestamptz,
requested_return_date date,
truck_plate_number varchar(32),
truck_driver_name varchar(120),
truck_type varchar(60),
scheduled_at timestamptz,
submitted_by_user_id uuid,
submitted_at timestamptz NOT NULL DEFAULT now(),
reviewed_by_staff_id uuid,
reviewed_at timestamptz,
rejection_reason text,
completed_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_empty_return_requests_booking
ON freight.empty_return_requests (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_empty_return_requests_status
ON freight.empty_return_requests (status)
`);
// A container number may only be owed back once at a time. That guard is
// per array element, so it lives in the service (see assertContainersFree)
// rather than in a unique index — this GIN index is what makes the check
// cheap.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_empty_return_requests_containers
ON freight.empty_return_requests USING gin (container_numbers)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`);
}
}