Merge remote-tracking branch 'origin/dev' into Truckdetantion

This commit is contained in:
Hagernesh
2026-07-07 08:49:20 +00:00
252 changed files with 16873 additions and 1684 deletions

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Vehicle Compliance & Expiry Alerts.
* - Adds expiry-tracking columns to freight.vehicles.
* - Creates freight.compliance_records for per-document compliance tracking.
*/
export class AddVehicleCompliance1950000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Vehicle expiry / compliance columns.
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS vin VARCHAR,
ADD COLUMN IF NOT EXISTS ownership VARCHAR,
ADD COLUMN IF NOT EXISTS insurance_expiry DATE,
ADD COLUMN IF NOT EXISTS registration_expiry DATE,
ADD COLUMN IF NOT EXISTS next_inspection_date DATE;
`);
// Compliance records table.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.compliance_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL REFERENCES freight.vehicles(id),
type VARCHAR NOT NULL,
document_number VARCHAR,
issued_date DATE,
expiry_date DATE NOT NULL,
status VARCHAR NOT NULL DEFAULT 'VALID',
notes 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_compliance_records_vehicle_id ON freight.compliance_records(vehicle_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_expiry_date ON freight.compliance_records(expiry_date);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_type ON freight.compliance_records(type);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.compliance_records CASCADE;`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS vin,
DROP COLUMN IF EXISTS ownership,
DROP COLUMN IF EXISTS insurance_expiry,
DROP COLUMN IF EXISTS registration_expiry,
DROP COLUMN IF EXISTS next_inspection_date;
`);
}
}

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,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Accident & Incident register for the fleet. Tracks accidents, breakdowns,
* traffic violations, thefts and other incidents against a vehicle, driver
* and/or booking, with severity, damage estimate, insurance claim tracking and
* a lifecycle status. Queried by driver_id for per-driver incident history.
*/
export class AddIncidents1960000000000 implements MigrationInterface {
name = 'AddIncidents1960000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.incidents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid,
driver_id uuid,
booking_id uuid,
type varchar NOT NULL,
severity varchar NOT NULL,
occurred_at timestamptz NOT NULL,
location varchar,
description text NOT NULL,
damage_estimate numeric(14,2),
status varchar NOT NULL DEFAULT 'REPORTED',
insurance_claim_number varchar,
reported_by varchar
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_DRIVER"
ON freight.incidents (driver_id, occurred_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_VEHICLE"
ON freight.incidents (vehicle_id, occurred_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`);
}
}

View File

@@ -0,0 +1,93 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMaintenanceDepth1970000000000 implements MigrationInterface {
name = 'AddMaintenanceDepth1970000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.work_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL,
title VARCHAR NOT NULL,
description TEXT,
status VARCHAR NOT NULL DEFAULT 'OPEN',
priority VARCHAR NOT NULL DEFAULT 'MEDIUM',
assigned_to VARCHAR,
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
labor_cost NUMERIC(14, 2),
parts_cost NUMERIC(14, 2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR NOT NULL,
sku VARCHAR,
category VARCHAR,
quantity_in_stock INT NOT NULL DEFAULT 0,
reorder_level INT NOT NULL DEFAULT 0,
unit_cost NUMERIC(14, 2),
location VARCHAR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warranties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL,
component VARCHAR NOT NULL,
provider VARCHAR,
start_date DATE,
expiry_date DATE NOT NULL,
coverage_notes 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_work_orders_vehicle_id_status" ON freight.work_orders (vehicle_id, status)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_parts_category" ON freight.parts (category)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warranties_vehicle_id_expiry_date" ON freight.warranties (vehicle_id, expiry_date)`,
);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.work_orders
ADD CONSTRAINT "FK_work_orders_vehicle_id"
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warranties
ADD CONSTRAINT "FK_warranties_vehicle_id"
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warranties CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.parts CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.work_orders CASCADE`);
}
}

View File

@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProcurement1980000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.vendors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
name varchar NOT NULL,
type varchar,
contact_person varchar,
phone varchar,
email varchar,
address varchar,
is_active boolean NOT NULL DEFAULT true
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.asset_acquisitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid,
vendor_id uuid,
acquisition_type varchar NOT NULL,
acquisition_date date NOT NULL,
cost numeric(14,2),
useful_life_months integer,
salvage_value numeric(14,2),
lease_start date,
lease_end date,
monthly_payment numeric(14,2),
status varchar NOT NULL DEFAULT 'ACTIVE',
notes text
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_acquisitions_vehicle_date
ON freight.asset_acquisitions(vehicle_id, acquisition_date);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.asset_disposals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid NOT NULL,
disposal_date date NOT NULL,
method varchar NOT NULL,
sale_price numeric(14,2),
buyer varchar,
notes text
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_disposals_vehicle_date
ON freight.asset_disposals(vehicle_id, disposal_date);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_disposals CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_acquisitions CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.vendors CASCADE;`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route
* freezes its trade direction from the yard countries:
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT,
* same country = DOMESTIC (shown as "Intercity"; disabled for scheduling
* and contracts for now).
*
* Existing yard rows are normalized case-insensitively; anything mentioning
* Djibouti maps there, everything else maps to Ethiopia (the line only serves
* these two countries). A CHECK constraint keeps future writes honest.
*/
export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface {
name = 'YardCountryEnumAndRouteDirection1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.yards
SET country = CASE
WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti'
ELSE 'Ethiopia'
END
`);
await queryRunner.query(`
ALTER TABLE freight.yards
DROP CONSTRAINT IF EXISTS chk_yards_country,
ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti'))
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
UPDATE freight.routes r
SET direction = CASE
WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT'
WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT'
ELSE 'DOMESTIC'
END
FROM freight.yards o, freight.yards d
WHERE o.id = r.origin_yard_id
AND d.id = r.destination_yard_id
`);
// Orphan origin/destination (deleted yard) — no way to classify; park as
// DOMESTIC, which is blocked everywhere, so nothing can schedule on it.
await queryRunner.query(`
UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ALTER COLUMN direction SET NOT NULL,
DROP CONSTRAINT IF EXISTS chk_routes_direction,
ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC'))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.routes
DROP CONSTRAINT IF EXISTS chk_routes_direction,
DROP COLUMN IF EXISTS direction
`);
await queryRunner.query(`
ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Per-km haulage rate on a vehicle (mainly trucks) plus the currency it's
* quoted in (ETB | USD, default ETB).
*/
export class AddVehiclePricePerKm1990000000000 implements MigrationInterface {
name = "AddVehiclePricePerKm1990000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS price_per_km numeric(14,2),
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS price_per_km,
DROP COLUMN IF EXISTS currency
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* GPS tracking: physical trackers (gps_devices, one denormalized latest fix per
* device for the live map) + append-only fix history (gps_positions).
*/
export class AddGpsTracking2000000000000 implements MigrationInterface {
name = "AddGpsTracking2000000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
imei varchar(20) NOT NULL UNIQUE,
name varchar,
vehicle_id uuid REFERENCES freight.vehicles(id),
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
last_seen_at timestamptz,
last_lat numeric(10,6),
last_lng numeric(10,6),
last_speed numeric(6,2),
last_course int,
last_fix_at timestamptz,
voltage_level int,
gsm_level int,
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_GPS_DEVICES_VEHICLE"
ON freight.gps_devices (vehicle_id)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
imei varchar(20) NOT NULL,
vehicle_id uuid,
lat numeric(10,6) NOT NULL,
lng numeric(10,6) NOT NULL,
speed numeric(6,2) NOT NULL DEFAULT 0,
course int NOT NULL DEFAULT 0,
satellites int NOT NULL DEFAULT 0,
positioned boolean NOT NULL DEFAULT false,
gps_time timestamptz NOT NULL,
alarm int 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_GPS_POSITIONS_DEVICE_TIME"
ON freight.gps_positions (device_id, gps_time)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
ON freight.gps_positions (vehicle_id, gps_time)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`);
}
}