Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-06 12:06:22 +00:00
300 changed files with 17177 additions and 5298 deletions

View File

@@ -0,0 +1,133 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Replace load-type string matching with a real wagon-type foreign key.
*
* Before this migration, train scheduling picked a wagon type by matching
* strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …)
* and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on
* `cargo_types` and `container_types` so scheduling resolves the wagon type
* through the relation instead.
*
* The columns are NULLABLE: cargo grouping rows and container/legacy cargo that
* never ship in bulk have no wagon type, and forcing one onto them is
* meaningless. Scheduling enforces the requirement at run time (it throws when a
* scheduled bulk cargo type or a container type in the batch has no wagon type).
*
* Backfill reproduces the old hardcoded resolution one final time so existing
* bulk cargo + container rows are not left unset. After this, the runtime map is
* removed — the FK is the single source of truth.
*/
export class AddWagonTypeFkToCargoAndContainerTypes1940000000000
implements MigrationInterface
{
name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Columns + FKs ────────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD CONSTRAINT fk_cargo_types_wagon_type
FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
ON DELETE RESTRICT;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD CONSTRAINT fk_container_types_wagon_type
FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
ON DELETE RESTRICT;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id
ON freight.cargo_types (wagon_type_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id
ON freight.container_types (wagon_type_id);
`);
// ── Backfill: old cargo-code → wagon-code map (one last time) ─────────────
// COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2,
// COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default).
const cargoCodeToWagon: Record<string, string> = {
COFFEE: "KW2",
GRAIN: "KW2",
WHEAT: "KW2",
SORGHUM: "KW2",
CORN: "KW2",
FERTILIZER: "PW2",
SUGAR: "PW2",
COAL: "KW3",
STEEL: "CW3",
ORE: "CW3",
};
for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) {
await queryRunner.query(
`
UPDATE freight.cargo_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = $1
AND UPPER(TRIM(ct.code)) = $2
AND ct.wagon_type_id IS NULL;
`,
[wagonCode, cargoCode],
);
}
// Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3.
await queryRunner.query(`
UPDATE freight.cargo_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = 'CW3'
AND ct.wagon_type_id IS NULL
AND ct.unit_of_measure = 'PER_TON';
`);
// All container types → the old container default wagon NW5.
await queryRunner.query(`
UPDATE freight.container_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = 'NW5'
AND ct.wagon_type_id IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id;
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
`);
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Multi-truck customer (self-haul) assignment. Replaces the single
* booking.customer_truck_* fields with a per-booking list of trucks, each
* carrying 12 containers and tracking its own arrival. The legacy
* booking.customer_truck_* columns are kept as a synced booking-level flag
* (any truck assigned / all trucks arrived) so the warehouse exit-gate and
* delivery-approval logic keep working.
*/
export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface {
name = 'AddCustomerTruckAssignments1950000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
plate_number varchar(32) NOT NULL,
driver_name varchar(120) NOT NULL,
truck_type varchar(60) NOT NULL,
assigned_at timestamptz NOT NULL DEFAULT now(),
arrived_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_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customer_truck_containers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE,
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
container_number varchar(64) NOT NULL,
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_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`,
);
// One container number can be loaded onto exactly one truck per booking.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number"
ON freight.customer_truck_containers (booking_id, container_number)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`);
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add the daily booking-desk close hour.
*
* The import booking window used to reopen only within the same EAT calendar day
* as its close; a cycle whose reopen crossed midnight died at CLOSED_FOR_DAY with
* capacity still free. The window now runs a daily office range [openHour,
* closeHour): a not-yet-full train pauses at closeHour and resumes the next
* morning at openHour, every day until it fills or departs. openHour === closeHour
* means a 24-hour desk.
*
* `window_close_hour` on the global-rules singleton is the live config; the
* matching `rule_window_close_hour` snapshot on each schedule freezes it at
* creation so the batch board keeps drawing the window the customer was shown.
* Both default/backfill to 17:00 (5 PM), the previous implicit office close.
*/
export class AddWindowCloseHour1950000000000 implements MigrationInterface {
name = "AddWindowCloseHour1950000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS window_close_hour integer NOT NULL DEFAULT 17;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS rule_window_close_hour integer;
`);
// Backfill the snapshot from the global-rules singleton so pre-existing
// schedules keep projecting reopen cycles.
await queryRunner.query(`
UPDATE freight.train_schedules ts
SET rule_window_close_hour = COALESCE(ts.rule_window_close_hour, r.window_close_hour)
FROM freight.train_scheduling_global_rules r
WHERE ts.rule_window_close_hour IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS rule_window_close_hour;
`);
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS window_close_hour;
`);
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* In-app notification inbox. One row per recipient per logical notification;
* producers fan out by inserting many rows. Indexed for the two hot queries:
* unread-count (recipient + is_read) and the newest-first list (recipient +
* created_at). Enum-like columns are stored as varchar to avoid PG enum churn.
*/
export class CreateNotifications1950000000000 implements MigrationInterface {
name = "CreateNotifications1950000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_user_id uuid NOT NULL,
audience varchar(20) NOT NULL,
type varchar(48) NOT NULL DEFAULT 'GENERIC',
title varchar(200) NOT NULL,
body text NOT NULL,
link varchar,
data jsonb,
priority varchar(12) NOT NULL DEFAULT 'NORMAL',
is_read boolean NOT NULL DEFAULT false,
read_at timestamptz,
channels_sent jsonb,
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_NOTIFICATIONS_RECIPIENT_UNREAD"
ON freight.notifications (recipient_user_id, is_read)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_CREATED"
ON freight.notifications (recipient_user_id, created_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_CREATED"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_UNREAD"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.notifications`);
}
}

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-container receive tracking. A booking's containers arrive individually
* (on separate self-haul trucks), so each container unit tracks whether it has
* been received into the port and, once staff confirm it, the GRN it belongs to.
* A single GRN covers the containers received together — so if the whole booking
* arrives at once, all its units share one GRN (per-booking GRN).
*/
export class AddContainerReceiptToBookingContainerUnits1960000000000
implements MigrationInterface
{
name = 'AddContainerReceiptToBookingContainerUnits1960000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_container_units
ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS received_at timestamptz,
ADD COLUMN IF NOT EXISTS grn_number varchar(100)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`);
await queryRunner.query(`
ALTER TABLE freight.booking_container_units
DROP COLUMN IF EXISTS received_to_port,
DROP COLUMN IF EXISTS received_at,
DROP COLUMN IF EXISTS grn_number
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Import self-haul trucks are weighed on leaving. The customer does not
* pre-specify what an import truck takes — staff register the containers loaded
* and the weighed gross when the truck departs. These columns capture that.
*/
export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface {
name = 'AddCustomerTruckDeparture1970000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_assignments
ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2),
ADD COLUMN IF NOT EXISTS departed_at timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_assignments
DROP COLUMN IF EXISTS gross_weight_kg,
DROP COLUMN IF EXISTS departed_at
`);
}
}