Merge freight/develop

This commit is contained in:
hagiye
2026-06-02 17:11:51 +03:00
171 changed files with 15339 additions and 5797 deletions

7
.gitignore vendored
View File

@@ -21,4 +21,9 @@ coverage/
# OS/editor
.DS_Store
.idea/
.vscode/
.vscode/
# emacs cache files
*~
\#*\#
.\#*

1002
ITMLS_DB_Design.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [{ "include": "migrations/**/*", "outDir": "dist" }],
"watchAssets": true
}
}

View File

@@ -1,6 +1,8 @@
import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
@@ -21,6 +23,10 @@ import { OtpModule } from './modules/otp/otp.module';
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import {
EDR_FREIGHT_APPLICATION,
EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
@@ -34,9 +40,20 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>("database")!,
dataSourceFactory: async (options) => {
if (!options) {
throw new Error("Missing TypeORM DataSource options");
}
await ensurePostgresSchemas(options as DataSourceOptions);
const dataSource = new DataSource(options as DataSourceOptions);
return dataSource.initialize();
},
}),
SharedAuthModule,
IamModule.forRoot(),
IamModule.forRoot({
applications: [EDR_FREIGHT_APPLICATION],
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
FilesModule,
ConsignmentsModule,

View File

@@ -0,0 +1,15 @@
/**
* Derives a stable, uppercase, underscore-separated code from a human-readable name.
*
* Examples:
* "Hazard Surcharge" → "HAZARD_SURCHARGE"
* "20ft Dry Container" → "20FT_DRY_CONTAINER"
* "Kality Yard (ET)" → "KALITY_YARD_ET"
*/
export function generateCode(name: string): string {
return name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}

View File

@@ -1,5 +1,6 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { join, dirname } from "path";
import {
DefaultPosition,
DefaultUnit,
@@ -44,6 +45,7 @@ import {
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
const iamEntities = [
DefaultPosition,
@@ -90,26 +92,37 @@ const iamEntities = [
NotificationTemplate,
];
const iamMigrationsGlob = join(
dirname(require.resolve("@tria-plc/iamapi-common/package.json")),
"dist/db/migrations/*.js",
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
(): TypeOrmModuleOptions => {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5433", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
schema: "public",
extra: {
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
},
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [
// IAM schema + tables must be created before freight entity sync
__dirname +
"/../../node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.js",
__dirname + "/../../migrations/*.{ts,js}",
// IAM schema + tables must be created before freight migrations
iamMigrationsGlob,
freightMigrationsGlob,
],
migrationsRun: true,
// Never enable synchronize in production. Use migrations.
synchronize: process.env.NODE_ENV === "development",
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging: process.env.NODE_ENV === "development",
}),
};
},
);

View File

@@ -0,0 +1,50 @@
import { DataSource, DataSourceOptions } from "typeorm";
/** Schemas required before TypeORM migrations and entity access. */
export const APPLICATION_SCHEMAS = [
"public",
"iam",
"freight",
"audit",
] as const;
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
/**
* TypeORM creates the migrations table before any migration runs. If `public` was
* dropped, current_schema() is null and CREATE TABLE migrations fails.
* IAM/freight migrations assume their schemas already exist.
*/
export async function ensurePostgresSchemas(
options: DataSourceOptions,
): Promise<void> {
const bootstrap = new DataSource({
...options,
entities: [],
migrations: [],
migrationsRun: false,
synchronize: false,
});
await bootstrap.initialize();
for (const schema of APPLICATION_SCHEMAS) {
if (schema === "public") {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`);
await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`);
await bootstrap.query(`GRANT CREATE ON SCHEMA public TO public`);
} else {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`);
await bootstrap.query(
`GRANT CREATE ON SCHEMA "${schema}" TO public`,
);
}
}
await bootstrap.query(
`SET search_path TO ${APPLICATION_SEARCH_PATH}`,
);
await bootstrap.destroy();
}

View File

@@ -5,7 +5,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
public async up(queryRunner: QueryRunner): Promise<void> {
// Create service_types table
await queryRunner.createTable(
if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable(
new Table({
name: "service_types",
schema: "freight",
@@ -109,7 +109,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
);
// Create cargo_types table
await queryRunner.createTable(
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
new Table({
name: "cargo_types",
schema: "freight",

View File

@@ -13,53 +13,69 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Add `code` column to existing tables ───────────────────────────
await queryRunner.addColumn(
'freight.service_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) {
await queryRunner.addColumn(
'freight.service_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.query(
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL`,
`UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.changeColumn(
'freight.service_types',
'code',
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
await queryRunner.query(
`ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`,
);
await queryRunner.createIndex(
'freight.service_types',
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
const serviceTypesCodeIdx = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`,
);
if (serviceTypesCodeIdx.length === 0) {
await queryRunner.createIndex(
'freight.service_types',
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
);
}
await queryRunner.addColumn(
'freight.cargo_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) {
await queryRunner.addColumn(
'freight.cargo_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.query(
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL`,
`UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.changeColumn(
'freight.cargo_types',
'code',
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
await queryRunner.query(
`ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`,
);
await queryRunner.createIndex(
'freight.cargo_types',
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
const cargoTypesCodeIdx = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`,
);
if (cargoTypesCodeIdx.length === 0) {
await queryRunner.createIndex(
'freight.cargo_types',
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
);
}
// ── 2. surcharge_types ────────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable(
new Table({
name: 'surcharge_types',
schema: 'freight',
@@ -87,7 +103,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
// ── 3. surcharges ─────────────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable(
new Table({
name: 'surcharges',
schema: 'freight',
@@ -136,7 +152,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
// ── 4. container_types ────────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable(
new Table({
name: 'container_types',
schema: 'freight',
@@ -164,7 +180,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
// ── 5. weight_limit_rules ─────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable(
new Table({
name: 'weight_limit_rules',
schema: 'freight',
@@ -229,7 +245,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
// ── 6. priority_rules ─────────────────────────────────────────────────
await queryRunner.createTable(
if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable(
new Table({
name: 'priority_rules',
schema: 'freight',

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Pre-ITMLS baseline for fresh databases. Older environments created `freight.bookings`
* via synchronize or manual SQL; ItmlsFullSchemaRewrite only ALTERs that table.
*/
export class CreateFreightLegacyBaseline1748550000000 implements MigrationInterface {
name = 'CreateFreightLegacyBaseline1748550000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.train_status AS ENUM (
'AVAILABLE', 'SCHEDULED', 'IN_SERVICE', 'UNDER_MAINTENANCE', 'OUT_OF_SERVICE'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.trains (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(32) NOT NULL UNIQUE,
capacity_tons NUMERIC(10, 2) NOT NULL DEFAULT 0,
status freight.train_status NOT NULL DEFAULT 'AVAILABLE',
notes TEXT,
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.bookings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reference VARCHAR(64) NOT NULL UNIQUE,
customer_id UUID NOT NULL,
train_id UUID,
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
scheduled_date TIMESTAMPTZ NOT NULL DEFAULT now(),
total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
contract_type VARCHAR(20) NOT NULL DEFAULT 'SPOT',
previous_contract_id UUID,
trade_direction VARCHAR(10) NOT NULL DEFAULT 'IMPORT',
equipment_return VARCHAR(20) NOT NULL DEFAULT 'RETURN',
first_mile_pickup_address TEXT,
last_mile_delivery_address TEXT,
cargo_total_weight_vgm NUMERIC(12, 3) NOT NULL DEFAULT 0,
is_hazardous BOOLEAN NOT NULL DEFAULT false,
payment_currency VARCHAR(5) NOT NULL DEFAULT 'USD',
start_date DATE,
end_date DATE,
financial_terms TEXT,
version_number INT NOT NULL DEFAULT 1,
approved_by_staff_id UUID,
approved_by_staff_at TIMESTAMPTZ,
signed_by_director_id UUID,
signed_by_director_at TIMESTAMPTZ,
signed_by_ceo_id UUID,
signed_by_ceo_at TIMESTAMPTZ,
priority_score INT NOT NULL DEFAULT 0,
allow_consolidation BOOLEAN NOT NULL DEFAULT false,
consolidation_partner_id UUID,
origin_station VARCHAR(255),
destination_station VARCHAR(255),
service_type VARCHAR(100),
freight_type VARCHAR(100),
freight_subtype VARCHAR(255),
containers JSONB,
first_mile_enabled BOOLEAN DEFAULT false,
last_mile_enabled BOOLEAN DEFAULT false,
is_refrigerated BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.bookings CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.trains CASCADE`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.train_status`);
}
}

View File

@@ -0,0 +1,544 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
TableUnique,
} from 'typeorm';
export class ItmlsFullSchemaRewrite1748600000000 implements MigrationInterface {
name = 'ItmlsFullSchemaRewrite1748600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── container_types ───────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.container_types RENAME COLUMN size_code TO code;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.container_types RENAME COLUMN description TO label;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS size_ft SMALLINT,
ADD COLUMN IF NOT EXISTS is_reefer BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_open_top BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS display_order INT NOT NULL DEFAULT 1;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagons_per_unit NUMERIC(4,2);
`);
await queryRunner.query(`
UPDATE freight.container_types
SET wagons_per_unit = CASE
WHEN containers_per_wagon > 0 THEN ROUND(1.0 / containers_per_wagon, 2)
ELSE 1.00
END
WHERE wagons_per_unit IS NULL;
`);
await queryRunner.query(`
UPDATE freight.container_types
SET size_ft = CASE WHEN code LIKE '40%' OR code LIKE '%40%' THEN 40 ELSE 20 END
WHERE size_ft IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ALTER COLUMN wagons_per_unit SET NOT NULL,
DROP COLUMN IF EXISTS containers_per_wagon;
`);
// ── weight_limit_rules ──────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.weight_limit_rules RENAME COLUMN max_weight_tons TO max_vgm_tons;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ALTER COLUMN max_vgm_tons TYPE NUMERIC(8,3);
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ADD COLUMN IF NOT EXISTS effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
ADD COLUMN IF NOT EXISTS effective_to DATE;
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
DROP COLUMN IF EXISTS warning_threshold_tons,
DROP COLUMN IF EXISTS exceeded_action,
DROP COLUMN IF EXISTS surcharge_id,
DROP COLUMN IF EXISTS is_active;
`);
// ── priority_rules ────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.priority_rules
ADD COLUMN IF NOT EXISTS code VARCHAR(40),
ADD COLUMN IF NOT EXISTS label VARCHAR(100),
ADD COLUMN IF NOT EXISTS score INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS condition_currency VARCHAR(5);
`);
await queryRunner.query(`
UPDATE freight.priority_rules
SET code = COALESCE(code, upper(priority_type::text)),
label = COALESCE(label, rule_name),
score = COALESCE(score, bonus_points)
WHERE code IS NULL OR label IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_rules
DROP COLUMN IF EXISTS priority_type,
DROP COLUMN IF EXISTS rule_name,
DROP COLUMN IF EXISTS bonus_points,
DROP COLUMN IF EXISTS activation_condition,
DROP COLUMN IF EXISTS description;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_rules
ALTER COLUMN code SET NOT NULL,
ALTER COLUMN label SET NOT NULL;
`);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'UQ_priority_rules_code', columnNames: ['code'], isUnique: true }),
);
// ── rates (before surcharge_types.rate_id) ────────────────────────────
await queryRunner.createTable(
new Table({
name: 'rates',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'rate_type', type: 'varchar', length: '50' },
{ name: 'container_type_id', type: 'uuid', isNullable: true },
{ name: 'trade_direction', type: 'varchar', length: '10', isNullable: true },
{ name: 'currency', type: 'varchar', length: '5' },
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
{ name: 'rate_unit', type: 'varchar', length: '30' },
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
{ name: 'proposed_by_staff_id', type: 'uuid' },
{ name: 'approved_by_ceo_id', type: 'uuid', isNullable: true },
{ name: 'approved_at', type: 'timestamptz', isNullable: true },
{ name: 'effective_from', type: 'date' },
{ name: 'effective_to', type: 'date', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// ── surcharge_types ───────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.surcharge_types RENAME COLUMN name TO label;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.surcharge_types
ADD COLUMN IF NOT EXISTS trigger_condition VARCHAR(50),
ADD COLUMN IF NOT EXISTS rate_id UUID;
`);
await queryRunner.query(`ALTER TABLE freight.surcharge_types DROP COLUMN IF EXISTS description`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharges CASCADE`);
// ── yards ─────────────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'yards',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '20' },
{ name: 'label', type: 'varchar', length: '100' },
{ name: 'country', type: 'varchar', length: '50' },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'display_order', type: 'int', default: 1 },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.yards',
new TableIndex({ name: 'UQ_yards_code', columnNames: ['code'], isUnique: true }),
);
// ── shipping_lines ────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'shipping_lines',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '20' },
{ name: 'label', type: 'varchar', length: '100' },
{ name: 'mapped_to_code', type: 'varchar', length: '20', isNullable: true },
{ name: 'show_extra_fee_notice', type: 'boolean', default: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// ── approval_rules ────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'approval_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'requires_director_approval', type: 'boolean' },
{ name: 'step_order', type: 'smallint' },
{ name: 'required_role', type: 'varchar', length: '30' },
{ name: 'action_label', type: 'varchar', length: '50' },
{ name: 'blocks_role', type: 'varchar', length: '30', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createUniqueConstraint(
'freight.approval_rules',
new TableUnique({
name: 'UQ_approval_rules_chain_step',
columnNames: ['requires_director_approval', 'step_order'],
}),
);
// ── bookings ────────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS origin_yard_id UUID,
ADD COLUMN IF NOT EXISTS destination_yard_id UUID,
ADD COLUMN IF NOT EXISTS service_type_id UUID,
ADD COLUMN IF NOT EXISTS cargo_type_id UUID,
ADD COLUMN IF NOT EXISTS cargo_free_text VARCHAR(200),
ADD COLUMN IF NOT EXISTS shipping_line_id UUID,
ADD COLUMN IF NOT EXISTS pnr_code VARCHAR(50),
ADD COLUMN IF NOT EXISTS customer_signed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS fully_executed_at TIMESTAMPTZ;
`);
await queryRunner.query(`
INSERT INTO freight.yards (id, code, label, country, is_active, display_order, created_at, updated_at)
VALUES
(uuid_generate_v4(), 'KALITY', 'Kality Rail Terminal', 'Ethiopia', true, 1, now(), now()),
(uuid_generate_v4(), 'MOJO', 'Mojo Dry Port', 'Ethiopia', true, 2, now(), now()),
(uuid_generate_v4(), 'DIRE_DAWA', 'Dire Dawa Yard', 'Ethiopia', true, 3, now(), now()),
(uuid_generate_v4(), 'DJIB_PORT', 'Djibouti Port Terminal', 'Djibouti', true, 4, now(), now()),
(uuid_generate_v4(), 'NAGAD', 'Nagad Terminal, Djibouti', 'Djibouti', true, 5, now(), now()),
(uuid_generate_v4(), 'LEGACY_ORIGIN', 'Legacy Origin', 'Ethiopia', true, 99, now(), now()),
(uuid_generate_v4(), 'LEGACY_DEST', 'Legacy Destination', 'Ethiopia', true, 100, now(), now())
ON CONFLICT (code) DO NOTHING;
`);
await queryRunner.query(`
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
`);
await queryRunner.query(`
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
`);
const hasServiceTypeCol = await queryRunner.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'freight' AND table_name = 'bookings' AND column_name = 'service_type'
LIMIT 1;
`);
if (hasServiceTypeCol.length > 0) {
await queryRunner.query(`
UPDATE freight.bookings b
SET service_type_id = st.id
FROM freight.service_types st
WHERE b.service_type_id IS NULL
AND (
st.code = b.service_type
OR upper(replace(st.service_name, ' ', '_')) = upper(b.service_type)
OR st.code = upper(replace(b.service_type, ' ', '_'))
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET cargo_type_id = ct.id
FROM freight.cargo_types ct
WHERE b.cargo_type_id IS NULL
AND (
ct.code = upper(b.freight_type)
OR ct.code = upper(concat(b.freight_type, '_', coalesce(b.freight_subtype, '')))
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET cargo_free_text = b.freight_subtype
WHERE b.cargo_free_text IS NULL AND b.freight_subtype IS NOT NULL;
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET origin_yard_id = y.id
FROM freight.yards y
WHERE b.origin_yard_id IS NULL
AND (y.label ILIKE b.origin_station OR y.code = upper(replace(b.origin_station, ' ', '_')));
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET destination_yard_id = y.id
FROM freight.yards y
WHERE b.destination_yard_id IS NULL
AND (y.label ILIKE b.destination_station OR y.code = upper(replace(b.destination_station, ' ', '_')));
`);
}
const defaultServiceTypeId = await queryRunner.query(
`SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1`,
);
const defaultCargoTypeId = await queryRunner.query(
`SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1`,
);
const legacyOriginId = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = 'LEGACY_ORIGIN' LIMIT 1`,
);
const legacyDestId = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = 'LEGACY_DEST' LIMIT 1`,
);
if (defaultServiceTypeId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET service_type_id = $1 WHERE service_type_id IS NULL`,
[defaultServiceTypeId[0].id],
);
}
if (defaultCargoTypeId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET cargo_type_id = $1 WHERE cargo_type_id IS NULL`,
[defaultCargoTypeId[0].id],
);
}
if (legacyOriginId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET origin_yard_id = $1 WHERE origin_yard_id IS NULL`,
[legacyOriginId[0].id],
);
}
if (legacyDestId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET destination_yard_id = $1 WHERE destination_yard_id IS NULL`,
[legacyDestId[0].id],
);
}
const nullBookings = await queryRunner.query(
`SELECT COUNT(*)::int AS cnt FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`,
);
if (nullBookings[0]?.cnt > 0) {
await queryRunner.query(`DELETE FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`);
}
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN service_type_id SET NOT NULL,
ALTER COLUMN cargo_type_id SET NOT NULL,
ALTER COLUMN origin_yard_id SET NOT NULL,
ALTER COLUMN destination_yard_id SET NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS origin_station,
DROP COLUMN IF EXISTS destination_station,
DROP COLUMN IF EXISTS service_type,
DROP COLUMN IF EXISTS freight_type,
DROP COLUMN IF EXISTS freight_subtype,
DROP COLUMN IF EXISTS containers,
DROP COLUMN IF EXISTS first_mile_enabled,
DROP COLUMN IF EXISTS last_mile_enabled,
DROP COLUMN IF EXISTS is_refrigerated;
`);
// ── booking_container ─────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'booking_container',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'container_type_id', type: 'uuid' },
{ name: 'quantity', type: 'smallint' },
{ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 },
{ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 },
{ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 },
{ name: 'weight_limit_rule_id', type: 'uuid', isNullable: true },
{ name: 'is_overweight', type: 'boolean', default: false },
{ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_rate_snapshot',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'rate_id', type: 'uuid' },
{ name: 'rate_type', type: 'varchar', length: '50' },
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
{ name: 'rate_unit', type: 'varchar', length: '30' },
{ name: 'currency', type: 'varchar', length: '5' },
{ name: 'snapshotted_at', type: 'timestamptz' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_cargo_modifier',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'surcharge_type_id', type: 'uuid' },
{ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, isNullable: true },
{ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 },
{ name: 'rate_snapshot_id', type: 'uuid' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_approval_step',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'approval_rule_id', type: 'uuid' },
{ name: 'step_order', type: 'smallint' },
{ name: 'required_role', type: 'varchar', length: '30' },
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
{ name: 'actioned_by_staff_id', type: 'uuid', isNullable: true },
{ name: 'actioned_at', type: 'timestamptz', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// Foreign keys
await queryRunner.createForeignKey(
'freight.surcharge_types',
new TableForeignKey({
name: 'FK_surcharge_types_rate_id',
columnNames: ['rate_id'],
referencedTableName: 'rates',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
await queryRunner.createForeignKey(
'freight.booking_container',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'bookings',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.bookings',
new TableForeignKey({
columnNames: ['origin_yard_id'],
referencedTableName: 'yards',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
await queryRunner.createForeignKey(
'freight.bookings',
new TableForeignKey({
columnNames: ['destination_yard_id'],
referencedTableName: 'yards',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_approval_step', true);
await queryRunner.dropTable('freight.booking_cargo_modifier', true);
await queryRunner.dropTable('freight.booking_rate_snapshot', true);
await queryRunner.dropTable('freight.booking_container', true);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS origin_station VARCHAR(255),
ADD COLUMN IF NOT EXISTS destination_station VARCHAR(255),
ADD COLUMN IF NOT EXISTS service_type VARCHAR(30),
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20),
ADD COLUMN IF NOT EXISTS freight_subtype VARCHAR(100),
ADD COLUMN IF NOT EXISTS containers JSONB,
ADD COLUMN IF NOT EXISTS first_mile_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS last_mile_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_refrigerated BOOLEAN DEFAULT false;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS origin_yard_id,
DROP COLUMN IF EXISTS destination_yard_id,
DROP COLUMN IF EXISTS service_type_id,
DROP COLUMN IF EXISTS cargo_type_id,
DROP COLUMN IF EXISTS cargo_free_text,
DROP COLUMN IF EXISTS shipping_line_id,
DROP COLUMN IF EXISTS pnr_code,
DROP COLUMN IF EXISTS customer_signed_at,
DROP COLUMN IF EXISTS fully_executed_at;
`);
await queryRunner.dropTable('freight.approval_rules', true);
await queryRunner.dropTable('freight.shipping_lines', true);
await queryRunner.dropTable('freight.yards', true);
await queryRunner.dropTable('freight.rates', true);
}
}

View File

@@ -0,0 +1,94 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingsConfigForeignKeys1748700000000 implements MigrationInterface {
name = 'AddBookingsConfigForeignKeys1748700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Ensure parent config rows exist for backfill
await queryRunner.query(`
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
`);
await queryRunner.query(`
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
`);
// Clear orphan shipping_line references (nullable FK)
await queryRunner.query(`
UPDATE freight.bookings b
SET shipping_line_id = NULL
WHERE b.shipping_line_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.shipping_lines sl WHERE sl.id = b.shipping_line_id
);
`);
// Backfill required FK columns
await queryRunner.query(`
UPDATE freight.bookings
SET service_type_id = (SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1)
WHERE service_type_id IS NULL
OR NOT EXISTS (SELECT 1 FROM freight.service_types st WHERE st.id = service_type_id);
`);
await queryRunner.query(`
UPDATE freight.bookings
SET cargo_type_id = (SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1)
WHERE cargo_type_id IS NULL
OR NOT EXISTS (SELECT 1 FROM freight.cargo_types ct WHERE ct.id = cargo_type_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_service_type_id"
FOREIGN KEY (service_type_id)
REFERENCES freight.service_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_cargo_type_id"
FOREIGN KEY (cargo_type_id)
REFERENCES freight.cargo_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_shipping_line_id"
FOREIGN KEY (shipping_line_id)
REFERENCES freight.shipping_lines(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_shipping_line_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_cargo_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_service_type_id";
`);
}
}

View File

@@ -0,0 +1,331 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationInterface {
name = 'AddBookingsRemainingForeignKeys1748800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const publicCustomersExists = await queryRunner.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'customers'
) AS exists
`);
const hasPublicCustomers = Boolean(publicCustomersExists[0]?.exists);
// ── freight.bookings: nullable FK cleanup ─────────────────────────────
await queryRunner.query(`
UPDATE freight.bookings b
SET train_id = NULL
WHERE b.train_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.id = b.train_id);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET previous_contract_id = NULL
WHERE b.previous_contract_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.previous_contract_id);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET consolidation_partner_id = NULL
WHERE b.consolidation_partner_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.consolidation_partner_id);
`);
// Legacy DBs only: public.customers is created/moved in MoveCustomersToFreightSchema (174890).
if (hasPublicCustomers) {
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.bookings b
WHERE bcm.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
USING freight.bookings b
WHERE bas.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
USING freight.bookings b
WHERE brs.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
USING freight.bookings b
WHERE bc.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.bookings b
WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES public.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
// ── freight.bookings FKs ────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_train_id"
FOREIGN KEY (train_id)
REFERENCES freight.trains(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_previous_contract_id"
FOREIGN KEY (previous_contract_id)
REFERENCES freight.bookings(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_consolidation_partner_id"
FOREIGN KEY (consolidation_partner_id)
REFERENCES freight.bookings(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_container ─────────────────────────────────────────
await queryRunner.query(`
UPDATE freight.booking_container bc
SET weight_limit_rule_id = NULL
WHERE bc.weight_limit_rule_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.weight_limit_rules wlr WHERE wlr.id = bc.weight_limit_rule_id
);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
WHERE NOT EXISTS (SELECT 1 FROM freight.container_types ct WHERE ct.id = bc.container_type_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_container
ADD CONSTRAINT "FK_booking_container_container_type_id"
FOREIGN KEY (container_type_id)
REFERENCES freight.container_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_container
ADD CONSTRAINT "FK_booking_container_weight_limit_rule_id"
FOREIGN KEY (weight_limit_rule_id)
REFERENCES freight.weight_limit_rules(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_rate_snapshot ─────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.booking_rate_snapshot brs
WHERE bcm.rate_snapshot_id = brs.id
AND (
NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id)
);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_rate_snapshot
ADD CONSTRAINT "FK_booking_rate_snapshot_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_rate_snapshot
ADD CONSTRAINT "FK_booking_rate_snapshot_rate_id"
FOREIGN KEY (rate_id)
REFERENCES freight.rates(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_approval_step ─────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bas.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.approval_rules ar WHERE ar.id = bas.approval_rule_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_approval_step
ADD CONSTRAINT "FK_booking_approval_step_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_approval_step
ADD CONSTRAINT "FK_booking_approval_step_approval_rule_id"
FOREIGN KEY (approval_rule_id)
REFERENCES freight.approval_rules(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_cargo_modifier ────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bcm.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.surcharge_types st WHERE st.id = bcm.surcharge_type_id)
OR NOT EXISTS (
SELECT 1 FROM freight.booking_rate_snapshot brs WHERE brs.id = bcm.rate_snapshot_id
);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_surcharge_type_id"
FOREIGN KEY (surcharge_type_id)
REFERENCES freight.surcharge_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_rate_snapshot_id"
FOREIGN KEY (rate_snapshot_id)
REFERENCES freight.booking_rate_snapshot(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_snapshot_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_approval_rule_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_rate_snapshot
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_rate_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_rate_snapshot
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP CONSTRAINT IF EXISTS "FK_booking_container_weight_limit_rule_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP CONSTRAINT IF EXISTS "FK_booking_container_container_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_consolidation_partner_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_previous_contract_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_train_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
}
}

View File

@@ -0,0 +1,185 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MoveCustomersToFreightSchema1748900000000 implements MigrationInterface {
name = 'MoveCustomersToFreightSchema1748900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
phone VARCHAR(20) NOT NULL,
company_name VARCHAR(200) NOT NULL,
company_email VARCHAR(150) NOT NULL,
company_phone VARCHAR(20) NOT NULL,
company_location VARCHAR(100) NOT NULL,
company_address TEXT NOT NULL,
customer_type VARCHAR(32),
status VARCHAR(32),
contact_person_name VARCHAR(100) NOT NULL,
contact_person_phone VARCHAR(20) NOT NULL,
tin_number VARCHAR(10) NOT NULL UNIQUE,
vat_number VARCHAR(50),
fan_number VARCHAR(16) NOT NULL UNIQUE,
general_manager_name VARCHAR(100) NOT NULL,
general_manager_email VARCHAR(150) NOT NULL,
general_manager_phone VARCHAR(20) NOT NULL,
poa_name VARCHAR(100),
poa_phone VARCHAR(20),
poa_address TEXT,
poa_email VARCHAR(150),
poa_location VARCHAR(100),
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_freight_customers_email"
ON freight.customers (email);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
ON freight.customers (user_id);
`);
// Copy rows from public.customers when that legacy table exists
await queryRunner.query(`
DO $$
DECLARE
has_public boolean;
has_user_id boolean;
has_userid boolean;
BEGIN
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'customers'
) INTO has_public;
IF NOT has_public THEN
RETURN;
END IF;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'user_id'
) INTO has_user_id;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'userid'
) INTO has_userid;
IF has_user_id THEN
INSERT INTO freight.customers (
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
created_at, updated_at
)
SELECT
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
COALESCE(created_at, now()), COALESCE(updated_at, now())
FROM public.customers
ON CONFLICT (id) DO NOTHING;
ELSIF has_userid THEN
INSERT INTO freight.customers (
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
created_at, updated_at
)
SELECT
id, userid, firstname, lastname, email, phone,
companyname, companyemail, companyphone, companylocation, companyaddress,
contactpersonname, contactpersonphone, tinnumber, vatnumber, fannumber,
generalmanagername, generalmanageremail, generalmanagerphone,
poaname, poaphone, poaaddress, poaemail, poalocation, notes,
COALESCE("createdAt", now()), COALESCE("updatedAt", now())
FROM public.customers
ON CONFLICT (id) DO NOTHING;
END IF;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.bookings b
WHERE bcm.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
USING freight.bookings b
WHERE bas.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
USING freight.bookings b
WHERE brs.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
USING freight.bookings b
WHERE bc.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.bookings b
WHERE NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES freight.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES public.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.customers CASCADE;`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Align weight_limit_rules.trade_direction with app code: IMPORT, EXPORT, BOTH (not ANY).
*/
export class NormalizeWeightLimitTradeDirectionBoth1749000000000
implements MigrationInterface
{
name = 'NormalizeWeightLimitTradeDirectionBoth1749000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
UPDATE freight.weight_limit_rules
SET trade_direction = 'BOTH'
WHERE trade_direction::text = 'ANY';
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
END $$;
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// No-op: ANY is not a valid enum value in PostgreSQL.
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateFreightFilesTable1749100000000 implements MigrationInterface {
name = 'CreateFreightFilesTable1749100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource_id UUID NOT NULL,
resource VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL,
name VARCHAR(500) NOT NULL,
url TEXT NOT NULL,
size INTEGER NOT NULL,
mime_type VARCHAR(255) 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_freight_files_resource"
ON freight.files (resource_id, resource);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource_code"
ON freight.files (resource_id, resource, code);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`);
}
}

View File

@@ -4,11 +4,14 @@ import {
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Put,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { BackofficeService } from "./backoffice.service";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
@ApiTags("backoffice")
@@ -16,6 +19,28 @@ import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto
export class BackofficeController {
constructor(private readonly backofficeService: BackofficeService) {}
@Post("organizations/:orgId/users")
@ApiOperation({ summary: "Create an organization user without assigning positions" })
createOrganizationUser(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@Body() dto: CreateOrganizationUserDto,
) {
return this.backofficeService.createOrganizationUser(organizationId, dto);
}
@Get("organizations/:orgId/employees")
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
getOrganizationEmployees(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@Query("skip") skip?: string,
@Query("take") take?: string,
) {
return this.backofficeService.getOrganizationEmployees(organizationId, {
skip,
take,
});
}
@Get("organizations/:orgId/employee-users/:userId/roles")
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
getEmployeeUserRoles(

View File

@@ -1,5 +1,10 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import {
Employee,
Organization,
UserCredential,
} from "@tria-plc/iamapi-common";
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
@@ -9,7 +14,16 @@ import { BackofficeController } from "./backoffice.controller";
import { BackofficeService } from "./backoffice.service";
@Module({
imports: [TypeOrmModule.forFeature([Role, UserRole, User])],
imports: [
TypeOrmModule.forFeature([
Employee,
Organization,
Role,
User,
UserCredential,
UserRole,
]),
],
controllers: [BackofficeController],
providers: [BackofficeService],
exports: [BackofficeService],

View File

@@ -4,21 +4,33 @@ import {
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { DataSource, In, IsNull, Repository } from "typeorm";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
const RESERVED_ROLE_KEYS = new Set([
"super_admin",
"organization_admin",
"unit_admin",
]);
const DEFAULT_USER_PASSWORD = "12345678";
const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin";
const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager";
@Injectable()
export class BackofficeService {
constructor(
@InjectRepository(Employee)
private readonly employeeRepository: Repository<Employee>,
@InjectRepository(Organization)
private readonly organizationRepository: Repository<Organization>,
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
@InjectRepository(UserRole)
@@ -28,6 +40,153 @@ export class BackofficeService {
private readonly dataSource: DataSource,
) {}
async createOrganizationUser(
organizationId: string,
dto: CreateOrganizationUserDto,
) {
const organizationExists = await this.organizationRepository.exists({
where: { id: organizationId },
});
if (!organizationExists) {
throw new NotFoundException("organization_not_found");
}
const email = dto.email.trim().toLowerCase();
const username = dto.username.trim().toLowerCase();
const phoneNumber = dto.phoneNumber?.trim() || undefined;
const assignOrganizationAdmin = dto.assignOrganizationAdmin === true;
const name = {
en: dto.name.en.trim(),
...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}),
};
const existingUsers = await this.userRepository.find({
where: [{ email }, { username }],
select: { id: true, email: true, username: true },
});
const emailUser = existingUsers.find((user) => user.email === email);
const usernameUser = existingUsers.find((user) => user.username === username);
if (emailUser && usernameUser && emailUser.id !== usernameUser.id) {
throw new BadRequestException("email_or_username_already_in_use");
}
const existingUser = emailUser ?? usernameUser;
const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD);
return this.dataSource.transaction(async (manager) => {
let user = existingUser;
if (!user) {
user = await manager.getRepository(User).save(
manager.getRepository(User).create({
email,
username,
phoneNumber,
name,
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
}),
);
} else {
await manager.getRepository(User).update(
{ id: user.id },
{
email,
username,
phoneNumber,
name,
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
},
);
}
const activeCredentialExists = await manager.getRepository(UserCredential).exists({
where: {
userId: user.id,
isActive: true,
},
});
if (!activeCredentialExists) {
await manager.getRepository(UserCredential).insert({
userId: user.id,
password: hashedPassword,
isActive: true,
});
}
let employee = await manager.getRepository(Employee).findOne({
where: {
userId: user.id,
organizationId,
isCurrent: true,
},
relations: {
user: true,
employeePositions: {
position: true,
},
},
});
if (!employee) {
const insertResult = await manager.getRepository(Employee).insert({
userId: user.id,
organizationId,
isCurrent: true,
name,
});
employee = await manager.getRepository(Employee).findOne({
where: { id: insertResult.identifiers[0]?.id as string },
relations: {
user: true,
employeePositions: {
position: true,
},
},
});
} else {
await manager.getRepository(Employee).update(
{ id: employee.id },
{ name },
);
employee = await manager.getRepository(Employee).findOne({
where: { id: employee.id },
relations: {
user: true,
employeePositions: {
position: true,
},
},
});
}
if (!employee) {
throw new NotFoundException("employee_create_failed");
}
const userId = user.id;
if (!userId) {
throw new NotFoundException("user_create_failed");
}
if (assignOrganizationAdmin) {
await this.ensureOrganizationAdminAccess(manager, organizationId, userId);
}
return employee;
});
}
async getEmployeeUserRoles(organizationId: string, userId: string) {
await this.assertUserBelongsToOrganization(organizationId, userId);
@@ -57,6 +216,45 @@ export class BackofficeService {
}));
}
async getOrganizationEmployees(
organizationId: string,
query: { skip?: string; take?: string },
) {
const organizationExists = await this.organizationRepository.exists({
where: { id: organizationId },
});
if (!organizationExists) {
throw new NotFoundException("organization_not_found");
}
const take = Number.parseInt(query.take ?? "1000", 10);
const skip = Number.parseInt(query.skip ?? "0", 10);
const employees = await this.employeeRepository.find({
where: {
organizationId,
isCurrent: true,
},
relations: {
user: true,
employeePositions: {
position: true,
},
},
order: {
createdAt: "DESC",
},
});
const deduplicated = this.mergeEmployeesByUser(employees);
return {
count: deduplicated.length,
items: deduplicated.slice(skip, skip + take),
};
}
async replaceEmployeeUserRoles(
organizationId: string,
userId: string,
@@ -124,4 +322,104 @@ export class BackofficeService {
throw new NotFoundException("user_not_found_in_organization");
}
}
private mergeEmployeesByUser(employees: Employee[]) {
const employeesByUserId = new Map<string, Employee>();
for (const employee of employees) {
const userId = employee.userId;
const employeeId = employee.id;
if (!userId) {
if (employeeId) {
employeesByUserId.set(employeeId, employee);
}
continue;
}
const existing = employeesByUserId.get(userId);
if (!existing) {
employeesByUserId.set(userId, employee);
continue;
}
const existingPositions = existing.employeePositions ?? [];
const nextPositions = employee.employeePositions ?? [];
const mergedEmployeePositions = Array.from(
new Map(
[...existingPositions, ...nextPositions].map((employeePosition) => [
employeePosition.id,
employeePosition,
]),
).values(),
);
employeesByUserId.set(userId, {
...existing,
...employee,
id: existing.id,
user: existing.user ?? employee.user,
userId,
name: existing.name ?? employee.name,
status: existing.status ?? employee.status,
employeePositions: mergedEmployeePositions,
});
}
return [...employeesByUserId.values()];
}
private async ensureOrganizationAdminAccess(
manager: EntityManager,
organizationId: string,
userId: string,
) {
const roles = await manager.getRepository(Role).find({
where: [
{ key: ORGANIZATION_ADMIN_ROLE_KEY },
{ key: EDR_ORG_MANAGER_ROLE_KEY },
],
select: { id: true, key: true },
});
const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => {
const role = roles.find((item) => item.key === key);
if (!role?.id) {
throw new NotFoundException(`required_role_not_seeded:${key}`);
}
return {
id: role.id,
key: role.key,
};
});
const existingRoleIds = new Set(
(
await manager.getRepository(UserRole).find({
where: {
userId,
organizationId,
},
select: { roleId: true },
})
).map((userRole) => userRole.roleId),
);
const rolesToInsert = requiredRoles
.filter((role) => !existingRoleIds.has(role.id))
.map((role) => ({
userId,
roleId: role.id,
organizationId,
}));
if (!rolesToInsert.length) {
return;
}
await manager.getRepository(UserRole).insert(rolesToInsert);
}
}

View File

@@ -0,0 +1,39 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
class CreateOrganizationUserNameDto {
@ApiProperty()
@IsString()
@MinLength(1)
en!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
am?: string;
}
export class CreateOrganizationUserDto {
@ApiProperty()
@IsEmail()
email!: string;
@ApiProperty()
@IsString()
@MinLength(1)
username!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
phoneNumber?: string;
@ApiProperty({ type: CreateOrganizationUserNameDto })
@IsObject()
name!: CreateOrganizationUserNameDto;
@ApiProperty({ required: false, default: false })
@IsOptional()
@IsBoolean()
assignOrganizationAdmin?: boolean;
}

View File

@@ -0,0 +1,186 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../rule-engine/interfaces/cargo-types.repository.interface';
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../rule-engine/interfaces/container-types.repository.interface';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../rule-engine/interfaces/service-types.repository.interface';
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
import {
IYardsRepository,
YARDS_REPOSITORY,
} from '../rule-engine/interfaces/yards.repository.interface';
import {
BookingReferenceCargoTypeChildDto,
BookingReferenceCargoTypeGroupDto,
BookingReferenceContainerSizeGroupDto,
BookingReferenceContainerTypeDto,
BookingReferenceDataDto,
BookingReferenceServiceDto,
BookingReferenceShippingLineDto,
BookingReferenceYardDto,
} from './dto/booking-reference-data.dto';
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
id: child.id,
name: child.cargoTypeName,
code: child.code,
show_free_text_box: child.showFreeTextBox,
}),
);
const group: BookingReferenceCargoTypeGroupDto = {
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
};
if (children.length > 0) {
group.children = children;
}
return group;
});
}
export function groupContainersBySize(
rows: ContainerType[],
): BookingReferenceContainerSizeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const bySize = new Map<string, ContainerType[]>();
for (const ct of active) {
const sizeKey =
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
const list = bySize.get(sizeKey) ?? [];
list.push(ct);
bySize.set(sizeKey, list);
}
const sortSizeKey = (key: string): number => {
if (key === 'other') return Number.MAX_SAFE_INTEGER;
const n = parseInt(key, 10);
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
};
return [...bySize.entries()]
.sort(([a], [b]) => sortSizeKey(a) - sortSizeKey(b))
.map(([size, types]) => ({
size,
types: types
.sort(
(a, b) =>
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
a.code.localeCompare(b.code),
)
.map(
(ct): BookingReferenceContainerTypeDto => ({
id: ct.id,
name: ct.label?.trim() ? ct.label : ct.code,
code: ct.code,
is_reefer: ct.isReefer ?? false,
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
}),
),
}));
}
@Injectable()
export class BookingReferenceDataService {
constructor(
@Inject(YARDS_REPOSITORY)
private readonly yardsRepository: IYardsRepository,
@Inject(CONTAINER_TYPES_REPOSITORY)
private readonly containerTypesRepository: IContainerTypesRepository,
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly serviceTypesRepository: IServiceTypesRepository,
@Inject(SHIPPING_LINES_REPOSITORY)
private readonly shippingLinesRepository: IShippingLinesRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
) {}
async getReferenceData(): Promise<BookingReferenceDataDto> {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
await Promise.all([
this.yardsRepository.findAll({
where: {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
order: { displayOrder: 'ASC', code: 'ASC' },
}),
this.containerTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
}),
this.serviceTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
}),
this.shippingLinesRepository.findAll({
where: { isActive: true },
order: { label: 'ASC', code: 'ASC' },
}),
this.cargoTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
}),
]);
return {
yard: yards.map(
(y): BookingReferenceYardDto => ({
id: y.id,
name: y.label,
code: y.code,
country: y.country,
}),
),
containers: groupContainersBySize(containerTypes),
service: serviceTypes.map(
(s): BookingReferenceServiceDto => ({
id: s.id,
name: s.serviceName,
code: s.code,
}),
),
shipping_line: shippingLines.map(
(sl): BookingReferenceShippingLineDto => ({
id: sl.id,
name: sl.label,
code: sl.code,
}),
),
cargo_type: buildCargoTypeTree(cargoTypes),
};
}
}

View File

@@ -18,11 +18,14 @@ import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import { BookingReferenceDataService } from "./booking-reference-data.service";
import { BookingsService } from "./bookings.service";
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
@@ -32,7 +35,10 @@ import { UpdateStatusDto } from "./dto/update-status.dto";
@Controller("bookings")
@ApiBearerAuth()
export class BookingsController {
constructor(private readonly bookingsService: BookingsService) { }
constructor(
private readonly bookingsService: BookingsService,
private readonly bookingReferenceDataService: BookingReferenceDataService,
) {}
// ── 1. Create booking (multipart/form-data) ──────────────────────────
@Post()
@@ -42,7 +48,7 @@ export class BookingsController {
summary: "Create a new freight booking",
description:
"Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
"Auto-enables consolidation when containerType=20FT and odd quantity.",
"Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.",
})
@ApiBody({
description:
@@ -92,14 +98,27 @@ export class BookingsController {
@ApiOperation({
summary: "List freight bookings (paginated)",
description:
"Filter by status, customerId, contractType, serviceType, tradeDirection, " +
"paymentCurrency, freightType, containerType, allowConsolidation, consolidationPaired. " +
"Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
"paymentCurrency, allowConsolidation, consolidationPaired. " +
"Sort by createdAt or priorityScore.",
})
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
}
// ── Booking form catalog (must be before :id) ─────────────────────────
@Get("reference-data")
@ApiOperation({
summary: "Booking form catalog",
description:
"Returns yards, container types (grouped by size), service types, shipping lines, " +
"and hierarchical cargo types for the booking UI in a single payload.",
})
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
@Get("by-reference/:reference")
@ApiOperation({
@@ -150,8 +169,8 @@ export class BookingsController {
@ApiOperation({
summary: "Request freight consolidation",
description:
"Searches for a compatible 20FT partner (same origin, destination, tradeDirection). " +
"If a partner is found, both bookings are paired. If not, the booking enters the consolidation queue.",
"Searches for a partner whose container quantity complements yours to fill whole wagon(s) " +
"(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.",
})
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);

View File

@@ -1,19 +1,42 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomersModule } from "../customers/customers.module";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { RuleEngineModule } from "../rule-engine/rule-engine.module";
import { BookingsController } from "./bookings.controller";
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { Booking } from "./entities/booking.entity";
import { CustomersModule } from '../customers/customers.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsController } from './bookings.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { Booking } from './entities/booking.entity';
@Module({
imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule, RuleEngineModule],
imports: [
TypeOrmModule.forFeature([
Booking,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
]),
FilesModule,
MinioModule,
CustomersModule,
RuleEngineModule,
],
controllers: [BookingsController],
providers: [BookingsService, BookingsRepository],
providers: [
BookingsService,
BookingsRepository,
ConsolidationService,
BookingReferenceDataService,
],
exports: [BookingsService],
})
export class BookingsModule {}

View File

@@ -1,16 +1,23 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, IsNull, Not, Repository } from "typeorm";
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { Booking } from "./entities/booking.entity";
import { FileRecord } from "../files/entities/file.entity";
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
constructor(
@InjectRepository(Booking)
repository: Repository<Booking>,
private readonly dataSource: DataSource,
) {
super(repository);
}
@@ -24,83 +31,250 @@ export class BookingsRepository extends BaseRepository<Booking> {
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
.createQueryBuilder("booking")
.where("booking.created_at >= :startDate", { startDate })
.andWhere("booking.created_at < :endDate", { endDate })
.createQueryBuilder('booking')
.where('booking.created_at >= :startDate', { startDate })
.andWhere('booking.created_at < :endDate', { endDate })
.getCount();
}
/** Find a booking by reference with associated files (polymorphic join). */
/** Find a booking by reference with files and relations. */
async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
const booking = await this.repository
.createQueryBuilder("booking")
.where("booking.reference = :reference", { reference })
.leftJoinAndMapMany(
"booking.files",
FileRecord,
"file",
"file.resource_id = booking.id AND file.resource = 'bookings'"
)
.getOne();
return booking ?? null;
return this.findByIdWithFiles(
(
await this.repository.findOne({ where: { reference }, select: ['id'] })
)?.id ?? '',
);
}
/** Find a booking by ID with associated files (polymorphic join). */
/** Find a booking by ID with files, containers, and config relations. */
async findByIdWithFiles(id: string): Promise<Booking | null> {
if (!id) return null;
const booking = await this.repository
.createQueryBuilder("booking")
.where("booking.id = :id", { id })
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.train', 'train')
.leftJoinAndSelect('booking.serviceType', 'st')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.originYard', 'oy')
.leftJoinAndSelect('booking.destinationYard', 'dy')
.leftJoinAndSelect('booking.shippingLine', 'sl')
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.where('booking.id = :id', { id })
.leftJoinAndMapMany(
"booking.files",
'booking.files',
FileRecord,
"file",
"file.resource_id = booking.id AND file.resource = 'bookings'"
'file',
"file.resource_id = booking.id AND file.resource = 'bookings'",
)
.getOne();
return booking ?? null;
}
/** Find a compatible consolidation partner for the given booking. */
async findConsolidationPartner(booking: Booking): Promise<Booking | null> {
return this.repository.findOne({
where: {
allowConsolidation: true,
// Check if containers JSONB contains at least one 20FT entry with odd qty
containers: Not(IsNull()),
originStation: booking.originStation,
destinationStation: booking.destinationStation,
/** Persist booking container rows with weight rule results. */
async createContainers(
bookingId: string,
containers: Array<{
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
weightResult: ContainerWeightResult;
}>,
): Promise<BookingContainer[]> {
const containerRepo = this.dataSource.getRepository(BookingContainer);
const typeRepo = this.dataSource.getRepository(ContainerType);
const saved: BookingContainer[] = [];
for (const item of containers) {
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const totalVgm = item.quantity * item.vgmPerUnitTons;
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
const row = containerRepo.create({
bookingId,
containerTypeId: item.containerTypeId,
quantity: item.quantity,
vgmPerUnitTons: item.vgmPerUnitTons,
totalVgmTons: totalVgm,
wagonsRequired,
weightLimitRuleId: item.weightResult.weightLimitRuleId,
isOverweight: item.weightResult.isOverweight,
overweightExcessTons: item.weightResult.overweightExcessTons,
});
saved.push(await containerRepo.save(row));
}
return saved;
}
/** SQL aggregate wagon count for a booking. */
async calculateWagonCount(bookingId: string): Promise<number> {
const result = await this.dataSource
.createQueryBuilder()
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
.from(BookingContainer, 'bc')
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
.where('bc.booking_id = :bookingId', { bookingId })
.getRawOne<{ total: string }>();
return Number(result?.total ?? 0);
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides).
*/
async findComplementaryConsolidationPartner(
booking: Booking,
slot: {
containerTypeId: string;
quantity: number;
containersPerWagon: number;
},
): Promise<Booking | null> {
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
return this.repository
.createQueryBuilder('b')
.innerJoinAndSelect('b.bookingContainers', 'bc')
.innerJoin('bc.containerType', 'ct')
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
.andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
})
.andWhere('b.destinationYardId = :destinationYardId', {
destinationYardId: booking.destinationYardId,
})
.andWhere('b.tradeDirection = :tradeDirection', {
tradeDirection: booking.tradeDirection,
consolidationPartnerId: IsNull(),
status: In(["DRAFT", "PENDING_CONSOLIDATION"]),
id: Not(booking.id),
},
order: { createdAt: "ASC" },
});
})
.andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId })
.andWhere('(bc.quantity % :perWagon) > 0', { perWagon })
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
quantity,
perWagon,
})
.orderBy('b.createdAt', 'ASC')
.getOne();
}
/** Try each partial-wagon line until a complementary partner booking is found. */
async findConsolidationPartner(
booking: Booking,
slots: Array<{
containerTypeId: string;
quantity: number;
containersPerWagon: number;
}>,
): Promise<Booking | null> {
for (const slot of slots) {
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
if (partner) return partner;
}
return null;
}
/** Pair two bookings for consolidation. */
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
status: "CONSOLIDATED",
status: 'CONSOLIDATED',
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
status: "CONSOLIDATED",
status: 'CONSOLIDATED',
} as never);
}
/** Un-pair a consolidation. Returns both booking IDs. */
/** Un-pair a consolidation. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: "PENDING_CONSOLIDATION",
status: 'PENDING_CONSOLIDATION',
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: null,
status: "PENDING_CONSOLIDATION",
status: 'PENDING_CONSOLIDATION',
} as never);
}
/** Delete all containers for a booking (used on draft update). */
async deleteContainers(bookingId: string): Promise<void> {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
/** Get pending approval step for a role. */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, requiredRole, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
/** Mark an approval step complete. */
async completeApprovalStep(
stepId: string,
actorId: string,
status: 'APPROVED' | 'REJECTED',
remarks?: string,
): Promise<void> {
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
status,
actionedByStaffId: actorId,
actionedAt: new Date(),
remarks,
});
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
where: { bookingId, status: 'PENDING' },
});
return pending === 0;
}
/** Persist cargo modifiers linked to rate snapshots. */
async createCargoModifiers(
rows: Array<{
bookingId: string;
surchargeTypeId: string;
triggerValue: number | null;
calculatedAmount: number;
rateSnapshotId: string;
}>,
): Promise<BookingCargoModifier[]> {
const repo = this.dataSource.getRepository(BookingCargoModifier);
const saved: BookingCargoModifier[] = [];
for (const row of rows) {
saved.push(await repo.save(repo.create(row)));
}
return saved;
}
/** Find rate snapshot by rate id for a booking. */
async findRateSnapshotByRateId(
bookingId: string,
rateId: string,
): Promise<BookingRateSnapshot | null> {
return this.dataSource.getRepository(BookingRateSnapshot).findOne({
where: { bookingId, rateId },
});
}
}

View File

@@ -3,20 +3,25 @@ import {
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { IsNull, Not } from "typeorm";
} from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import { CustomersService } from "../customers/customers.service";
import { FilesService } from "../files/files.service";
import { MinioService } from "../minio/minio.service";
import { RuleEngineService } from "../rule-engine/rule-engine.service";
import { BookingsRepository } from "./bookings.repository";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import { UpdateStatusDto } from "./dto/update-status.dto";
import { Booking } from "./entities/booking.entity";
import { FileRecord } from "../files/entities/file.entity";
import { CustomersService } from '../customers/customers.service';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { UpdateStatusDto } from './dto/update-status.dto';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
@Injectable()
export class BookingsService {
@@ -26,53 +31,116 @@ export class BookingsService {
private readonly minioService: MinioService,
private readonly customersService: CustomersService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
) {}
// ── helpers ──────────────────────────────────────────────────────────
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const prefix = `BK-${year}`;
// Get the count of bookings created this year
const count = await this.bookingsRepository.countByYear(year);
const sequenceNumber = String(count + 1).padStart(6, '0');
return `${prefix}-${sequenceNumber}`;
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
/** Resolve auto-consolidation flag. */
private resolveConsolidation(
containers: Array<{ type: string; qty: number }> | undefined | null,
explicit?: boolean,
): boolean {
if (explicit === false) return false;
if (!containers || containers.length === 0) return explicit ?? false;
// Auto-enable if any 20FT container has odd quantity
const needsConsolidation = containers.some(
(c) => c.type === "20FT" && c.qty % 2 !== 0
/** Build evaluation input from DTO containers. */
private async buildEvalInput(
dto: Pick<
CreateBookingDto,
| 'cargoTypeId'
| 'serviceTypeId'
| 'paymentCurrency'
| 'tradeDirection'
| 'isHazardous'
| 'allowConsolidation'
| 'shippingLineId'
| 'containers'
>,
): Promise<BookingEvaluationInput> {
const containers = await Promise.all(
dto.containers.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
return {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
};
}),
);
if (needsConsolidation) return true;
return {
cargoTypeId: dto.cargoTypeId,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
allowConsolidation: dto.allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
};
}
/**
* Enable consolidation when any container line leaves a wagon partially filled
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
*/
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
if (explicit === false) return false;
const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
})),
);
if (needs) return true;
return explicit ?? false;
}
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
private calculateWagonCount(
containers: Array<{ type: string; qty: number }>,
): number {
return containers.reduce((total, container) => {
if (container.type === "40FT") {
return total + container.qty;
}
// 20FT: 1 wagon per 2 containers (rounded up)
return total + Math.ceil(container.qty / 2);
}, 0);
/** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
private async tryAutoConsolidate(booking: Booking): Promise<{
booking: Booking;
messages: string[];
}> {
const messages: string[] = [];
if (!booking.allowConsolidation || booking.consolidationPartnerId) {
return { booking, messages };
}
const slots = await this.consolidationService.slotsFromBooking(booking);
if (slots.length === 0) {
return { booking, messages };
}
const partner = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
const paired = await this.findById(booking.id);
messages.push(
this.consolidationService.describePaired(partner.reference, slots),
);
return { booking: paired, messages };
}
if (booking.status === 'DRAFT') {
await this.bookingsRepository.update(booking.id, {
status: 'PENDING_CONSOLIDATION',
} as never);
}
const pending = await this.findById(booking.id);
messages.push(this.consolidationService.describePending(pending, slots));
return { booking: pending, messages };
}
// ── CRUD ─────────────────────────────────────────────────────────────
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -81,65 +149,90 @@ export class BookingsService {
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
// Resolve customerId: use provided value (admin) or look up by IAM userId
let customerId = dto.customerId;
if (!customerId) {
if (!userId) {
throw new BadRequestException('customerId is required or must be resolvable from auth token');
throw new BadRequestException(
'customerId is required or must be resolvable from auth token',
);
}
const customer = await this.customersService.findByUserId(userId);
customerId = customer.id;
}
// Generate reference if not provided
const reference = dto.reference || await this.generateReference();
const allowConsolidation = this.resolveConsolidation(
const reference = dto.reference || (await this.generateReference());
const allowConsolidation = await this.resolveConsolidation(
dto.containers,
dto.allowConsolidation,
);
// ── Rule engine evaluation ──────────────────────────────────────────
const ruleResult = await this.ruleEngineService.evaluate({
freightType: dto.freightType,
serviceType: dto.serviceType,
paymentCurrency: dto.paymentCurrency,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
isRefrigerated: dto.isRefrigerated ?? false,
containers: dto.containers,
});
const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation });
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const wagonCount = this.calculateWagonCount(dto.containers);
warnings.push(`Estimated wagons required: ${wagonCount}`);
const booking = await this.bookingsRepository.create({
...dto,
reference,
customerId,
totalAmount: 0,
paymentStatus: "PENDING",
trainId: dto.trainId,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
cargoTypeId: dto.cargoTypeId,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
isHazardous: dto.isHazardous ?? false,
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
scheduledDate: new Date(dto.scheduledDate),
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: "DRAFT",
status: 'DRAFT',
allowConsolidation,
priorityScore: ruleResult.priorityScore,
totalAmount: 0,
paymentStatus: 'PENDING',
});
await this.bookingsRepository.createContainers(
booking.id,
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
warnings.push(`Estimated wagons required: ${wagonCount}`);
if (files.length > 0) {
try {
await this.filesService.uploadMany(booking.id, "bookings", files);
} catch (err) {
console.error('[BookingsService] File upload failed, booking still created:', err);
await this.filesService.uploadMany(booking.id, 'bookings', files);
} catch {
warnings.push('File upload failed — booking was created without attached files.');
}
}
return { booking, warnings };
let full = await this.findById(booking.id);
if (allowConsolidation) {
const consolidation = await this.tryAutoConsolidate(full);
full = consolidation.booking;
warnings.push(...consolidation.messages);
}
return { booking: full, warnings };
}
/** Update a draft booking. */
@@ -149,45 +242,73 @@ export class BookingsService {
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const existing = await this.findById(id);
if (existing.status !== "DRAFT") {
throw new BadRequestException("Only DRAFT bookings can be updated");
if (existing.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT bookings can be updated');
}
const warnings: string[] = [];
const updates: Record<string, unknown> = { ...dto };
const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
})) ?? [];
const allowConsolidation = await this.resolveConsolidation(
containers,
dto.allowConsolidation ?? existing.allowConsolidation,
);
const evalInput = await this.buildEvalInput({
cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const updates: Record<string, unknown> = {
...dto,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
};
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
delete updates.containers;
// Re-evaluate consolidation if containers changed
const containers = dto.containers ?? existing.containers ?? [];
updates.allowConsolidation = this.resolveConsolidation(
containers,
dto.allowConsolidation,
);
await this.bookingsRepository.update(id, updates);
// ── Rule engine re-evaluation ────────────────────────────────────────
const ruleResult = await this.ruleEngineService.evaluate({
freightType: dto.freightType ?? existing.freightType,
serviceType: dto.serviceType ?? existing.serviceType,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm ?? existing.cargoTotalWeightVgm,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous ?? false,
isRefrigerated: dto.isRefrigerated ?? existing.isRefrigerated ?? false,
containers,
});
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
updates.priorityScore = ruleResult.priorityScore;
if (files.length > 0) {
await this.filesService.uploadMany(id, "bookings", files);
if (dto.containers) {
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
}
const booking = await this.bookingsRepository.update(id, updates);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
if (files.length > 0) {
await this.filesService.uploadMany(id, 'bookings', files);
}
let booking = await this.findById(id);
if (allowConsolidation && !booking.consolidationPartnerId) {
const consolidation = await this.tryAutoConsolidate(booking);
booking = consolidation.booking;
warnings.push(...consolidation.messages);
}
return { booking, warnings };
}
@@ -203,19 +324,21 @@ export class BookingsService {
if (filter.status) where.status = filter.status;
if (filter.customerId) where.customerId = filter.customerId;
if (filter.contractType) where.contractType = filter.contractType;
if (filter.serviceType) where.serviceType = filter.serviceType;
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
if (filter.freightType) where.freightType = filter.freightType;
if (filter.allowConsolidation !== undefined)
if (filter.allowConsolidation !== undefined) {
where.allowConsolidation = filter.allowConsolidation;
if (filter.consolidationPaired === "true")
}
if (filter.consolidationPaired === 'true') {
where.consolidationPartnerId = Not(IsNull());
else if (filter.consolidationPaired === "false")
} else if (filter.consolidationPaired === 'false') {
where.consolidationPartnerId = IsNull();
}
const sortField = filter.sortBy ?? "createdAt";
const sortDir = filter.sortOrder ?? "DESC";
const sortField = filter.sortBy ?? 'createdAt';
const sortDir = filter.sortOrder ?? 'DESC';
const [items, total] = await this.bookingsRepository.findAndCount({
where,
@@ -226,325 +349,336 @@ export class BookingsService {
return { items, total };
}
/** Get a single booking by ID with files, throwing if not found. */
/** Get a single booking by ID with files. */
async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) {
throw new NotFoundException(`Booking ${id} not found`);
}
// Add signed URLs for files (5-minute expiration)
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.extractObjectName(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
})
}),
);
}
return booking;
}
/** Extract object name from Minio URL. */
private extractObjectName(url: string): string {
const parts = url.split("/");
return parts.slice(4).join("/");
const parts = url.split('/');
return parts.slice(4).join('/');
}
/** Find booking by reference with files. */
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
if (!booking) {
throw new NotFoundException(`Booking with reference "${reference}" not found`);
}
// Add signed URLs for files (5-minute expiration)
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.extractObjectName(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
})
);
}
return booking;
return this.findById(booking.id);
}
/** Soft-delete a booking (DRAFT only). */
async remove(id: string): Promise<void> {
const booking = await this.findById(id);
if (booking.status !== "DRAFT") {
throw new BadRequestException("Only DRAFT bookings can be deleted");
if (booking.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT bookings can be deleted');
}
await this.bookingsRepository.softDelete(id);
}
// ── status workflow ──────────────────────────────────────────────────
/** Unified status transition handler. */
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
const booking = await this.findById(id);
const { action, actorId, reason } = dto;
const { action, actorId, reason, requiredRole } = dto;
switch (action) {
case "SUBMIT":
case 'SUBMIT':
return this.handleSubmit(booking);
case "APPROVE_STAFF":
return this.handleApproveStaff(booking, actorId);
case "APPROVE_DIRECTOR":
return this.handleApproveDirector(booking, actorId);
case "APPROVE_CEO":
return this.handleApproveCeo(booking, actorId);
case "REJECT":
case 'SEND_QUOTATION':
return this.handleSendQuotation(booking);
case 'APPROVE_QUOTATION':
return this.handleApproveQuotation(booking);
case 'REJECT_QUOTATION':
return this.handleRejectQuotation(booking, reason);
case 'APPROVE_STEP':
return this.handleApproveStep(booking, actorId, requiredRole);
case 'APPROVE':
return this.handleFullyApproved(booking);
case 'CUSTOMER_SIGN':
return this.handleCustomerSign(booking);
case 'MARK_FULLY_EXECUTED':
return this.handleFullyExecuted(booking);
case 'MARK_PAID':
return this.handleMarkPaid(booking);
case 'START_TRANSIT':
return this.handleStartTransit(booking);
case 'COMPLETE':
return this.handleComplete(booking);
case 'REJECT':
return this.handleReject(booking, actorId, reason);
case "CANCEL":
return this.handleCancel(booking, actorId, reason);
case "ACTIVATE":
return this.handleActivate(booking);
case "EXPIRE":
return this.handleExpire(booking);
case 'CANCEL':
return this.handleCancel(booking, reason);
default:
throw new BadRequestException(`Unknown action: ${action}`);
}
}
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (cargo routing from rule engine). */
/** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
private async handleSubmit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["DRAFT"]);
const ruleResult = await this.ruleEngineService.evaluate(booking);
const nextStatus = ruleResult.requiresDirectorApproval
? "PENDING_DIRECTOR"
: "PENDING_LINE_STAFF";
this.assertStatus(booking, ['DRAFT']);
await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never);
await this.ruleEngineService.snapshotLiveRates(booking.id);
await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId);
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,
status: 'PENDING_APPROVAL',
} as never);
return updated!;
}
/** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */
private async handleApproveStaff(
private async handleSendQuotation(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['RFQ_SUBMITTED']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'QUOTATION_SENT',
} as never);
return updated!;
}
private async handleApproveQuotation(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['QUOTATION_SENT']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'QUOTATION_APPROVED',
} as never);
return updated!;
}
private async handleRejectQuotation(booking: Booking, reason?: string): Promise<Booking> {
this.assertStatus(booking, ['QUOTATION_SENT']);
if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION');
const updated = await this.bookingsRepository.update(booking.id, {
status: 'QUOTATION_REJECTED',
} as never);
return updated!;
}
private async handleApproveStep(
booking: Booking,
actorId?: string,
requiredRole?: string,
): Promise<Booking> {
this.assertStatus(booking, ["PENDING_LINE_STAFF"]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_STAFF");
// Line staff cannot approve bookings that require director approval
const ruleResult = await this.ruleEngineService.evaluate(booking);
if (ruleResult.requiresDirectorApproval) {
throw new BadRequestException(
"Line staff cannot approve bookings that require director approval",
);
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
if (!actorId || !requiredRole) {
throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
}
const step = await this.bookingsRepository.findPendingApprovalStep(
booking.id,
requiredRole,
);
if (!step) {
throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
}
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id);
if (allDone) {
const updated = await this.bookingsRepository.update(booking.id, {
status: 'APPROVED',
} as never);
return updated!;
}
return this.findById(booking.id);
}
private async handleFullyApproved(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "APPROVED_PENDING_SIGNATURE",
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
status: 'APPROVED',
} as never);
return updated!;
}
/** APPROVE_DIRECTOR: APPROVED_PENDING_SIGNATURE|PENDING_DIRECTOR → SIGNED or PENDING_CEO. */
private async handleApproveDirector(
booking: Booking,
actorId?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"APPROVED_PENDING_SIGNATURE",
"PENDING_DIRECTOR",
]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_DIRECTOR");
const ruleResult = await this.ruleEngineService.evaluate(booking);
const nextStatus = ruleResult.requiresDirectorApproval ? "PENDING_CEO" : "SIGNED";
private async handleCustomerSign(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['APPROVED']);
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,
signedByDirectorId: actorId,
signedByDirectorAt: new Date(),
status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(),
} as never);
return updated!;
}
/** APPROVE_CEO: PENDING_CEO → SIGNED. */
private async handleApproveCeo(
booking: Booking,
actorId?: string,
): Promise<Booking> {
this.assertStatus(booking, ["PENDING_CEO"]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_CEO");
private async handleFullyExecuted(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "SIGNED",
signedByCeoId: actorId,
signedByCeoAt: new Date(),
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
} as never);
return updated!;
}
/** REJECT: PENDING_* → CANCELLED. */
private async handleReject(
booking: Booking,
actorId?: string,
reason?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
]);
if (!actorId || !reason)
throw new BadRequestException("actorId and reason are required for REJECT");
private async handleMarkPaid(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "CANCELLED",
status: 'PAID',
paymentStatus: 'PAID',
} as never);
return updated!;
}
/** CANCEL: DRAFT|PENDING_* → CANCELLED. */
private async handleCancel(
booking: Booking,
_actorId?: string,
reason?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"DRAFT",
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
]);
if (!reason)
throw new BadRequestException("reason is required for CANCEL");
private async handleStartTransit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['PAID']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "CANCELLED",
status: 'IN_TRANSIT',
} as never);
return updated!;
}
/** ACTIVATE: SIGNED → ACTIVE. */
private async handleActivate(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["SIGNED"]);
private async handleComplete(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['IN_TRANSIT']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "ACTIVE",
startDate: booking.startDate ?? new Date(),
} as never);
return updated!;
}
/** EXPIRE: ACTIVE → EXPIRED. */
private async handleExpire(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["ACTIVE"]);
const updated = await this.bookingsRepository.update(booking.id, {
status: "EXPIRED",
status: 'COMPLETED',
endDate: new Date(),
} as never);
return updated!;
}
/** Guard: ensure current status is one of the allowed values. */
private async handleReject(
booking: Booking,
actorId?: string,
reason?: string,
): Promise<Booking> {
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
if (!actorId || !reason) {
throw new BadRequestException('actorId and reason are required for REJECT');
}
const updated = await this.bookingsRepository.update(booking.id, {
status: 'CANCELLED',
} as never);
return updated!;
}
private async handleCancel(booking: Booking, reason?: string): Promise<Booking> {
this.assertStatus(booking, [
'DRAFT',
'RFQ_SUBMITTED',
'QUOTATION_SENT',
'QUOTATION_APPROVED',
'PENDING_APPROVAL',
]);
if (!reason) throw new BadRequestException('reason is required for CANCEL');
const updated = await this.bookingsRepository.update(booking.id, {
status: 'CANCELLED',
} as never);
return updated!;
}
private assertStatus(booking: Booking, allowed: string[]): void {
if (!allowed.includes(booking.status)) {
throw new ConflictException(
`Cannot perform this action on a booking with status "${booking.status}". Allowed: ${allowed.join(", ")}`,
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
);
}
}
// ── consolidation ────────────────────────────────────────────────────
/** Request consolidation — auto-pair if a partner exists, else queue. */
async requestConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking | null;
paired: boolean;
message: string;
}> {
const booking = await this.findById(id);
if (!booking.allowConsolidation) {
throw new BadRequestException("Booking is not eligible for consolidation");
throw new BadRequestException('Booking is not eligible for consolidation');
}
// Check if any 20FT container has odd quantity
const hasOdd20FT = booking.containers?.some(
(c) => c.type === "20FT" && c.qty % 2 !== 0
) ?? false;
if (!hasOdd20FT) {
const needs = await this.consolidationService.needsConsolidationFromBooking(
booking,
);
if (!needs) {
throw new BadRequestException(
"Only bookings with odd-quantity 20FT containers need consolidation",
'Booking already fills whole wagon(s) for all container lines; consolidation is not required',
);
}
if (booking.consolidationPartnerId) {
throw new ConflictException("Booking is already paired for consolidation");
throw new ConflictException('Booking is already paired for consolidation');
}
const partner =
await this.bookingsRepository.findConsolidationPartner(booking);
const result = await this.tryAutoConsolidate(booking);
const partner = result.booking.consolidationPartnerId
? await this.findById(result.booking.consolidationPartnerId)
: null;
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
const updated = await this.findById(id);
const updatedPartner = await this.findById(partner.id);
return { booking: updated, partner: updatedPartner, paired: true };
}
// No partner found — enter queue
await this.bookingsRepository.update(booking.id, {
status: "PENDING_CONSOLIDATION",
} as never);
const updated = await this.findById(id);
return { booking: updated, partner: null, paired: false };
return {
booking: result.booking,
partner,
paired: partner !== null,
message: result.messages[0] ?? '',
};
}
/** Remove consolidation pairing. */
async removeConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking;
}> {
async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> {
const booking = await this.findById(id);
if (!booking.consolidationPartnerId) {
throw new BadRequestException("Booking has no consolidation partner");
throw new BadRequestException('Booking has no consolidation partner');
}
const partnerId = booking.consolidationPartnerId;
await this.bookingsRepository.unpairConsolidation(id, partnerId);
const updated = await this.findById(id);
const updatedPartner = await this.findById(partnerId);
return { booking: updated, partner: updatedPartner };
return {
booking: await this.findById(id),
partner: await this.findById(partnerId),
};
}
/** Get consolidation details for a booking. */
async getConsolidationDetails(id: string): Promise<{
booking: Booking;
partner: Booking | null;
splitBilling: { bookingShare: number; partnerShare: number } | null;
wagonSlots: Awaited<ReturnType<ConsolidationService['slotsFromBooking']>>;
statusMessage: string;
}> {
const booking = await this.findById(id);
const wagonSlots = await this.consolidationService.slotsFromBooking(booking);
if (!booking.consolidationPartnerId) {
return { booking, partner: null, splitBilling: null };
const statusMessage =
booking.status === 'PENDING_CONSOLIDATION'
? this.consolidationService.describePending(booking, wagonSlots)
: wagonSlots.length > 0
? 'Consolidation may be required; no partner paired yet.'
: 'No wagon consolidation needed.';
return {
booking,
partner: null,
splitBilling: null,
wagonSlots,
statusMessage,
};
}
const partner = await this.findById(booking.consolidationPartnerId);
const splitBilling = {
bookingShare: booking.totalAmount,
partnerShare: partner.totalAmount,
return {
booking,
partner,
splitBilling: {
bookingShare: Number(booking.totalAmount),
partnerShare: Number(partner.totalAmount),
},
wagonSlots,
statusMessage: this.consolidationService.describePaired(
partner.reference,
wagonSlots,
),
};
return { booking, partner, splitBilling };
}
}

View File

@@ -0,0 +1,123 @@
import { Injectable } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { Booking } from './entities/booking.entity';
export interface ConsolidationSlot {
containerTypeId: string;
containerTypeCode: string;
quantity: number;
containersPerWagon: number;
remainder: number;
slotsNeeded: number;
}
export interface ConsolidationAttemptResult {
booking: Booking;
partner: Booking | null;
paired: boolean;
messages: string[];
}
/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */
export function containersPerWagon(wagonsPerUnit: number): number {
const wpu = Number(wagonsPerUnit);
if (!wpu || wpu <= 0) return 1;
return Math.max(1, Math.round(1 / wpu));
}
export function wagonRemainder(quantity: number, perWagon: number): number {
const r = quantity % perWagon;
return r;
}
export function slotsNeededToFillWagon(quantity: number, perWagon: number): number {
const remainder = wagonRemainder(quantity, perWagon);
if (remainder === 0) return 0;
return perWagon - remainder;
}
/** Two bookings' quantities for the same type complete whole wagon(s). */
export function quantitiesComplementWagon(
q1: number,
q2: number,
perWagon: number,
): boolean {
return (
wagonRemainder(q1, perWagon) > 0 &&
wagonRemainder(q2, perWagon) > 0 &&
(q1 + q2) % perWagon === 0
);
}
@Injectable()
export class ConsolidationService {
constructor(private readonly containerTypesService: ContainerTypesService) {}
async slotsFromContainerLines(
lines: Array<{ containerTypeId: string; quantity: number }>,
): Promise<ConsolidationSlot[]> {
const slots: ConsolidationSlot[] = [];
for (const line of lines) {
const ct = await this.containerTypesService.findById(line.containerTypeId);
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
const remainder = wagonRemainder(line.quantity, perWagon);
if (remainder === 0) continue;
slots.push({
containerTypeId: line.containerTypeId,
containerTypeCode: ct.code,
quantity: line.quantity,
containersPerWagon: perWagon,
remainder,
slotsNeeded: perWagon - remainder,
});
}
return slots;
}
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
const lines =
booking.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
})) ?? [];
return this.slotsFromContainerLines(lines);
}
async needsConsolidation(
lines: Array<{ containerTypeId: string; quantity: number }>,
): Promise<boolean> {
const slots = await this.slotsFromContainerLines(lines);
return slots.length > 0;
}
async needsConsolidationFromBooking(booking: Booking): Promise<boolean> {
const slots = await this.slotsFromBooking(booking);
return slots.length > 0;
}
describePending(_booking: Booking, slots: ConsolidationSlot[]): string {
if (slots.length === 0) {
return 'Booking does not require wagon consolidation.';
}
const parts = slots.map(
(s) =>
`${s.slotsNeeded} more × ${s.containerTypeCode} (${s.containersPerWagon} per wagon; you have ${s.quantity})`,
);
return (
`No compatible partner found yet. Your booking is queued (PENDING_CONSOLIDATION). ` +
`Waiting for: ${parts.join('; ')}. You will be notified when another customer fills the wagon.`
);
}
describePaired(partnerReference: string, slots: ConsolidationSlot[]): string {
const parts = slots.map(
(s) =>
`${s.containerTypeCode}: ${s.quantity} + partner fills wagon (${s.containersPerWagon} per wagon)`,
);
return (
`Consolidation partner found (${partnerReference}). ` +
`Shared wagon confirmed: ${parts.join('; ')}.`
);
}
}

View File

@@ -0,0 +1,107 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BookingReferenceYardDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Mojo Dry Port' })
name!: string;
@ApiProperty({ example: 'MOJO' })
code!: string;
@ApiProperty({ example: 'Ethiopia' })
country!: string;
}
export class BookingReferenceContainerTypeDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Dry' })
name!: string;
@ApiProperty({ example: '20GP' })
code!: string;
@ApiProperty()
is_reefer!: boolean;
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
wagons_per_unit!: number;
}
export class BookingReferenceContainerSizeGroupDto {
@ApiProperty({ example: '20ft' })
size!: string;
@ApiProperty({ type: [BookingReferenceContainerTypeDto] })
types!: BookingReferenceContainerTypeDto[];
}
export class BookingReferenceServiceDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Rail Transport Only' })
name!: string;
@ApiProperty({ example: 'RAIL' })
code!: string;
}
export class BookingReferenceShippingLineDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'MSC' })
name!: string;
@ApiProperty({ example: 'MSC' })
code!: string;
}
export class BookingReferenceCargoTypeChildDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Coffee' })
name!: string;
@ApiProperty({ example: 'BULK_COFFEE' })
code!: string;
@ApiProperty()
show_free_text_box!: boolean;
}
export class BookingReferenceCargoTypeGroupDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Bulk Cargo' })
name!: string;
@ApiProperty({ example: 'BULK' })
code!: string;
@ApiPropertyOptional({ type: [BookingReferenceCargoTypeChildDto] })
children?: BookingReferenceCargoTypeChildDto[];
}
export class BookingReferenceDataDto {
@ApiProperty({ type: [BookingReferenceYardDto] })
yard!: BookingReferenceYardDto[];
@ApiProperty({ type: [BookingReferenceContainerSizeGroupDto] })
containers!: BookingReferenceContainerSizeGroupDto[];
@ApiProperty({ type: [BookingReferenceServiceDto] })
service!: BookingReferenceServiceDto[];
@ApiProperty({ type: [BookingReferenceShippingLineDto] })
shipping_line!: BookingReferenceShippingLineDto[];
@ApiProperty({ type: [BookingReferenceCargoTypeGroupDto] })
cargo_type!: BookingReferenceCargoTypeGroupDto[];
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
@@ -12,117 +12,81 @@ import {
IsUUID,
Min,
ValidateNested,
} from "class-validator";
} from 'class-validator';
import { BOOKING_STATUSES } from '../entities/booking.entity';
const BOOKING_STATUSES = [
"DRAFT",
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
"SIGNED",
"ACTIVE",
"EXPIRED",
"CANCELLED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
] as const;
const CONTRACT_TYPES = ["NEW", "RENEWAL"] as const;
const SERVICE_TYPES = ["RAIL_ONLY", "RAIL_AND_FORWARDING"] as const;
const EQUIPMENT_RETURNS = ["WITH_RETURN", "WITHOUT_RETURN"] as const;
const FREIGHT_TYPES = ["BULK", "BREAK_BULK"] as const;
const TRADE_DIRECTIONS = ["IMPORT", "EXPORT"] as const;
const PAYMENT_CURRENCIES = ["ETB", "USD"] as const;
const PAYMENT_STATUSES = ["PENDING", "PAID", "OVERDUE", "CANCELLED", "REFUNDED"] as const;
const CONTAINER_TYPES = ["20FT", "40FT"] as const;
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export {
BOOKING_STATUSES,
CONTRACT_TYPES,
SERVICE_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
PAYMENT_STATUSES,
CONTAINER_TYPES,
};
export class ContainerItem {
@ApiProperty({ enum: CONTAINER_TYPES, description: "Container type (20FT or 40FT)" })
@IsIn([...CONTAINER_TYPES])
type!: string;
export class CreateBookingContainerDto {
@ApiProperty({ format: 'uuid', description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ description: "Quantity of containers", minimum: 1 })
@ApiProperty({ description: 'Quantity of containers', minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
qty!: number;
quantity!: number;
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgm!: number;
vgmPerUnitTons!: number;
}
export class CreateBookingDto {
// ── core ─────────────────────────────────────────────────────────────
@ApiPropertyOptional({ description: "Unique booking reference (auto-generated if not provided)" })
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
@Transform(({ value }) => (typeof value === "string" ? value.trim() : value))
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
@ApiPropertyOptional({ format: "uuid", description: "Admin only: target customer. Omit to resolve from auth token." })
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional({ format: "uuid" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainId?: string;
@ApiProperty({ example: "2026-06-15T00:00:00.000Z" })
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;
// ── contract ─────────────────────────────────────────────────────────
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@ApiPropertyOptional({ format: "uuid", description: "For RENEWAL — previous contract/booking ID" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
@Transform(({ value }) => (value === "" || value == null ? undefined : value))
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
previousContractId?: string;
@ApiProperty({ enum: SERVICE_TYPES })
@IsIn([...SERVICE_TYPES])
serviceType!: string;
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
@IsUUID()
serviceTypeId!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
firstMileEnabled?: boolean;
@ApiPropertyOptional({ description: "Required when firstMileEnabled is true" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
lastMileEnabled?: boolean;
@ApiPropertyOptional({ description: "Required when lastMileEnabled is true" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@@ -131,55 +95,59 @@ export class CreateBookingDto {
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;
@ApiProperty({ description: "Origin station name or code" })
@IsString()
originStation!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
originYardId!: string;
@ApiProperty({ description: "Destination station name or code" })
@IsString()
destinationStation!: string;
@ApiProperty({ description: "Total cargo weight in VGM tons", minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
@ApiProperty({ enum: FREIGHT_TYPES })
@IsIn([...FREIGHT_TYPES])
freightType!: string;
@ApiPropertyOptional({ description: "Coffee, Beans, Machinery, Ro-Ro, etc." })
@IsOptional()
@IsString()
freightSubtype?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isHazardous?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isRefrigerated?: boolean;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
@IsUUID()
destinationYardId!: string;
@ApiProperty({ enum: TRADE_DIRECTIONS })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' })
@IsUUID()
cargoTypeId!: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' })
@IsOptional()
@IsUUID()
shippingLineId?: string;
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;
@ApiPropertyOptional({ example: "2026-06-15" })
@ApiPropertyOptional()
@IsOptional()
@IsString()
pnrCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ example: "2027-06-15" })
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
endDate?: string;
@@ -189,19 +157,15 @@ export class CreateBookingDto {
@IsString()
financialTerms?: string;
// ── containers ────────────────────────────────────────────────────────
@ApiProperty({ type: [ContainerItem], description: "Array of container specifications" })
@ApiProperty({ type: [CreateBookingContainerDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContainerItem)
containers!: ContainerItem[];
@Type(() => CreateBookingContainerDto)
containers!: CreateBookingContainerDto[];
@ApiPropertyOptional({
default: false,
description: "Auto-set to true when any 20FT container has odd quantity. User may override.",
})
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
}

View File

@@ -1,15 +1,7 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator";
import {
BOOKING_STATUSES,
CONTRACT_TYPES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
SERVICE_TYPES,
TRADE_DIRECTIONS,
} from "./create-booking.dto";
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -17,20 +9,24 @@ export class FilterBookingDto {
@IsIn([...BOOKING_STATUSES])
status?: string;
@ApiPropertyOptional({ format: "uuid" })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional({ enum: CONTRACT_TYPES })
@ApiPropertyOptional()
@IsOptional()
@IsIn([...CONTRACT_TYPES])
contractType?: string;
@ApiPropertyOptional({ enum: SERVICE_TYPES })
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsIn([...SERVICE_TYPES])
serviceType?: string;
@IsUUID()
serviceTypeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@@ -42,43 +38,31 @@ export class FilterBookingDto {
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@ApiPropertyOptional()
@IsOptional()
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
@ApiPropertyOptional({ description: "Filter by consolidation partner presence (true = paired, false = unpaired)" })
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
@IsOptional()
@IsString()
consolidationPaired?: string;
@ApiPropertyOptional({ enum: ["createdAt", "priorityScore"], default: "createdAt" })
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
pageSize?: number;
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
@IsIn(["createdAt", "priorityScore"])
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -1,41 +1,40 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString, IsUUID } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
const STATUS_ACTIONS = [
"SUBMIT",
"APPROVE_STAFF",
"APPROVE_DIRECTOR",
"APPROVE_CEO",
"REJECT",
"CANCEL",
"ACTIVATE",
"EXPIRE",
'SUBMIT',
'SEND_QUOTATION',
'APPROVE_QUOTATION',
'REJECT_QUOTATION',
'APPROVE_STEP',
'APPROVE',
'CUSTOMER_SIGN',
'MARK_FULLY_EXECUTED',
'MARK_PAID',
'START_TRANSIT',
'COMPLETE',
'REJECT',
'CANCEL',
] as const;
export { STATUS_ACTIONS };
export class UpdateStatusDto {
@ApiProperty({
enum: STATUS_ACTIONS,
description:
"SUBMIT — send to approval queue | " +
"APPROVE_STAFF — line-staff approval | " +
"APPROVE_DIRECTOR — director approval / signature | " +
"APPROVE_CEO — CEO final signature | " +
"REJECT — reject at any pending stage | " +
"CANCEL — customer / admin cancellation | " +
"ACTIVATE — activate a signed booking | " +
"EXPIRE — mark an active booking as expired",
})
@ApiProperty({ enum: STATUS_ACTIONS })
@IsIn([...STATUS_ACTIONS])
action!: string;
@ApiPropertyOptional({ format: "uuid", description: "Actor performing the action (staff/director/CEO)" })
@ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
@IsOptional()
@IsUUID()
actorId?: string;
@ApiPropertyOptional({ description: "Required for REJECT and CANCEL actions" })
@ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' })
@IsOptional()
@IsString()
requiredRole?: string;
@ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' })
@IsOptional()
@IsString()
reason?: string;

View File

@@ -0,0 +1,45 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity';
import { Booking } from './booking.entity';
export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const;
export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number];
@Entity({ schema: 'freight', name: 'booking_approval_step' })
@Index(['bookingId'])
@Index(['status'])
@Index(['bookingId', 'stepOrder'])
export class BookingApprovalStep extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'approval_rule_id', type: 'uuid' })
approvalRuleId!: string;
@ManyToOne(() => ApprovalRule)
@JoinColumn({ name: 'approval_rule_id' })
approvalRule?: ApprovalRule;
@Column({ name: 'step_order', type: 'smallint' })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 30 })
requiredRole!: string;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ApprovalStepStatus;
@Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true })
actionedByStaffId?: string | null;
@Column({ name: 'actioned_at', type: 'timestamptz', nullable: true })
actionedAt?: Date | null;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity';
import { Booking } from './booking.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
@Entity({ schema: 'freight', name: 'booking_cargo_modifier' })
@Index(['bookingId'])
@Index(['surchargeTypeId'])
export class BookingCargoModifier extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.cargoModifiers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'surcharge_type_id', type: 'uuid' })
surchargeTypeId!: string;
@ManyToOne(() => SurchargeType)
@JoinColumn({ name: 'surcharge_type_id' })
surchargeType?: SurchargeType;
@Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true })
triggerValue?: number | null;
@Column({ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 })
calculatedAmount!: number;
@Column({ name: 'rate_snapshot_id', type: 'uuid' })
rateSnapshotId!: string;
@ManyToOne(() => BookingRateSnapshot)
@JoinColumn({ name: 'rate_snapshot_id' })
rateSnapshot?: BookingRateSnapshot;
}

View File

@@ -0,0 +1,49 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { Booking } from './booking.entity';
@Entity({ schema: 'freight', name: 'booking_container' })
@Index(['bookingId'])
@Index(['isOverweight'])
export class BookingContainer extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.bookingContainers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@ManyToOne(() => ContainerType)
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType;
@Column({ name: 'quantity', type: 'smallint' })
quantity!: number;
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
vgmPerUnitTons!: number;
@Column({ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 })
totalVgmTons!: number;
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 })
wagonsRequired!: number;
@Column({ name: 'weight_limit_rule_id', type: 'uuid', nullable: true })
weightLimitRuleId?: string | null;
@ManyToOne(() => WeightLimitRule, { nullable: true })
@JoinColumn({ name: 'weight_limit_rule_id' })
weightLimitRule?: WeightLimitRule | null;
@Column({ name: 'is_overweight', type: 'boolean', default: false })
isOverweight!: boolean;
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
overweightExcessTons?: number | null;
}

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
@Entity({ schema: 'freight', name: 'booking_rate_snapshot' })
@Index(['bookingId'])
@Index(['rateId'])
@Index(['rateType'])
export class BookingRateSnapshot extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.rateSnapshots, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'rate_id', type: 'uuid' })
rateId!: string;
@ManyToOne(() => Rate)
@JoinColumn({ name: 'rate_id' })
rate?: Rate;
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
rateType!: string;
@Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 })
rateValue!: number;
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
rateUnit!: string;
@Column({ name: 'currency', type: 'varchar', length: 5 })
currency!: string;
@Column({ name: 'snapshotted_at', type: 'timestamptz' })
snapshottedAt!: Date;
}

View File

@@ -1,148 +1,201 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, OneToMany } from "typeorm";
import { FileRecord } from "../../files/entities/file.entity";
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Customer } from '../../customers/entities/customer.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Train } from '../../trains/entities/train.entity';
import { FileRecord } from '../../files/entities/file.entity';
import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
@Entity({ schema:"freight",name: "bookings" })
export const BOOKING_STATUSES = [
'DRAFT',
'RFQ_SUBMITTED',
'QUOTATION_SENT',
'QUOTATION_APPROVED',
'QUOTATION_REJECTED',
'PENDING_APPROVAL',
'APPROVED',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'PAID',
'IN_TRANSIT',
'COMPLETED',
'CANCELLED',
'PENDING_CONSOLIDATION',
'CONSOLIDATED',
] as const;
@Entity({ schema: 'freight', name: 'bookings' })
export class Booking extends BaseEntity {
// ── core ───────────────────────────────────────────────────────────────
@Column({ name: "reference", type: "varchar", length: 64, unique: true })
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
@Column({ name: "customer_id", type: "uuid" })
@Column({ name: 'customer_id', type: 'uuid' })
customerId!: string;
@Column({ name: "train_id", type: "uuid", nullable: true })
@ManyToOne(() => Customer)
@JoinColumn({ name: 'customer_id' })
customer?: Customer;
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null;
@Column({ name: "status", type: "varchar", length: 40, default: "DRAFT" })
@ManyToOne(() => Train, { nullable: true })
@JoinColumn({ name: 'train_id' })
train?: Train | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
@Column({ name: "scheduled_date", type: "timestamptz" })
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({
name: "total_amount",
type: "numeric",
precision: 14,
scale: 2,
default: 0,
})
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
@Column({
name: "payment_status",
type: "varchar",
length: 20,
default: "PENDING",
})
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
paymentStatus!: string;
// ── contract ───────────────────────────────────────────────────────────
@Column({ name: "contract_type", type: "varchar", length: 20 })
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
contractType!: string;
@Column({ name: "previous_contract_id", type: "uuid", nullable: true })
@Column({ name: 'previous_contract_id', type: 'uuid', nullable: true })
previousContractId?: string | null;
@Column({ name: "service_type", type: "varchar", length: 30 })
serviceType!: string;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'previous_contract_id' })
previousContract?: Booking | null;
@Column({ name: "first_mile_enabled", type: "boolean", default: false })
firstMileEnabled!: boolean;
@Column({ name: 'service_type_id', type: 'uuid' })
serviceTypeId!: string;
@Column({ name: "first_mile_pickup_address", type: "text", nullable: true })
@ManyToOne(() => ServiceType)
@JoinColumn({ name: 'service_type_id' })
serviceType?: ServiceType;
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
firstMilePickupAddress?: string | null;
@Column({ name: "last_mile_enabled", type: "boolean", default: false })
lastMileEnabled!: boolean;
@Column({ name: "last_mile_delivery_address", type: "text", nullable: true })
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
lastMileDeliveryAddress?: string | null;
@Column({ name: "equipment_return", type: "varchar", length: 20 })
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;
@Column({ name: "origin_station", type: "varchar", length: 255 })
originStation!: string;
@Column({ name: "destination_station", type: "varchar", length: 255 })
destinationStation!: string;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@Column({
name: "cargo_total_weight_vgm",
type: "numeric",
precision: 12,
scale: 3,
})
cargoTotalWeightVgm!: number;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: "freight_type", type: "varchar", length: 20 })
freightType!: string;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@Column({ name: "freight_subtype", type: "varchar", length: 100, nullable: true })
freightSubtype?: string | null;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
@Column({ name: "is_hazardous", type: "boolean", default: false })
isHazardous!: boolean;
@Column({ name: "is_refrigerated", type: "boolean", default: false })
isRefrigerated!: boolean;
@Column({ name: "trade_direction", type: "varchar", length: 10 })
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
@Column({ name: "payment_currency", type: "varchar", length: 5 })
@Column({ name: 'cargo_type_id', type: 'uuid' })
cargoTypeId!: string;
@ManyToOne(() => CargoType)
@JoinColumn({ name: 'cargo_type_id' })
cargoType?: CargoType;
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
cargoFreeText?: string | null;
@Column({ name: 'shipping_line_id', type: 'uuid', nullable: true })
shippingLineId?: string | null;
@ManyToOne(() => ShippingLine, { nullable: true })
@JoinColumn({ name: 'shipping_line_id' })
shippingLine?: ShippingLine | null;
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
cargoTotalWeightVgm!: number;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
paymentCurrency!: string;
@Column({ name: "start_date", type: "date", nullable: true })
@Column({ name: 'pnr_code', type: 'varchar', length: 50, nullable: true })
pnrCode?: string | null;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate?: Date | null;
@Column({ name: "end_date", type: "date", nullable: true })
@Column({ name: 'end_date', type: 'date', nullable: true })
endDate?: Date | null;
@Column({ name: "financial_terms", type: "text", nullable: true })
@Column({ name: 'financial_terms', type: 'text', nullable: true })
financialTerms?: string | null;
@Column({ name: "version_number", type: "int", default: 1 })
@Column({ name: 'version_number', type: 'int', default: 1 })
versionNumber!: number;
// ── containers ─────────────────────────────────────────────────────────
@Column({ name: "containers", type: "jsonb", nullable: true })
containers!: Array<{ type: string; qty: number; vgm: number }> | null;
// ── approval ───────────────────────────────────────────────────────────
@Column({ name: "approved_by_staff_id", type: "uuid", nullable: true })
@Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true })
approvedByStaffId?: string | null;
@Column({ name: "approved_by_staff_at", type: "timestamptz", nullable: true })
@Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true })
approvedByStaffAt?: Date | null;
@Column({ name: "signed_by_director_id", type: "uuid", nullable: true })
@Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true })
signedByDirectorId?: string | null;
@Column({ name: "signed_by_director_at", type: "timestamptz", nullable: true })
@Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true })
signedByDirectorAt?: Date | null;
@Column({ name: "signed_by_ceo_id", type: "uuid", nullable: true })
@Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true })
signedByCeoId?: string | null;
@Column({ name: "signed_by_ceo_at", type: "timestamptz", nullable: true })
@Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true })
signedByCeoAt?: Date | null;
@Column({ name: "priority_score", type: "int", default: 0 })
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
customerSignedAt?: Date | null;
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
fullyExecutedAt?: Date | null;
@Column({ name: 'priority_score', type: 'int', default: 0 })
priorityScore!: number;
// ── consolidation ──────────────────────────────────────────────────────
@Column({ name: "allow_consolidation", type: "boolean", default: false })
@Column({ name: 'allow_consolidation', type: 'boolean', default: false })
allowConsolidation!: boolean;
@Column({ name: "consolidation_partner_id", type: "uuid", nullable: true })
@Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true })
consolidationPartnerId?: string | null;
// ── files ────────────────────────────────────────────────────────────
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'consolidation_partner_id' })
consolidationPartner?: Booking | null;
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
cargoModifiers?: BookingCargoModifier[];
@OneToMany(() => BookingApprovalStep, (s) => s.booking)
approvalSteps?: BookingApprovalStep[];
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
rateSnapshots?: BookingRateSnapshot[];
@OneToMany(() => FileRecord, (file) => file.resourceId, {
createForeignKeyConstraints: false,
})
files?: FileRecord[];
}

View File

@@ -44,7 +44,7 @@ export class CustomersRepository {
async findByName(name: string): Promise<Customer[]> {
return await this.repository
.createQueryBuilder("customer")
.where("customer.name ILIKE :name", { name: `%${name}%` })
.where("customer.companyName ILIKE :name", { name: `%${name}%` })
.getMany();
}

View File

@@ -43,7 +43,7 @@ export class ResponseCustomerDto {
this.contactPersonName = customer.contactPersonName;
this.contactPersonPhone = customer.contactPersonPhone;
this.tinNumber = customer.tinNumber;
this.vatNumber = customer.vatNumber;
this.vatNumber = customer.vatNumber ?? undefined;
this.fanNumber = customer.fanNumber;
this.generalManagerName = customer.generalManagerName;
this.generalManagerEmail = customer.generalManagerEmail;

View File

@@ -1,100 +1,87 @@
import {
Column,
Entity,
CreateDateColumn,
UpdateDateColumn,
Index,
BaseEntity,
PrimaryGeneratedColumn,
} from "typeorm";
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
@Entity("customers")
@Entity({ schema: 'freight', name: 'customers' })
@Index(['email'])
@Index(['userId'])
@Index(['tinNumber'])
@Index(['fanNumber'])
export class Customer extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid" })
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId!: string;
@Column({ length: 100 })
@Index()
@Column({ name: 'first_name', type: 'varchar', length: 100 })
firstName!: string;
@Column({ length: 100 })
@Index()
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ unique: true, length: 150 })
@Index()
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
email!: string;
@Column({ length: 20 })
@Column({ name: 'phone', type: 'varchar', length: 20 })
phone!: string;
@Column({ length: 200 })
@Index()
@Column({ name: 'company_name', type: 'varchar', length: 200 })
companyName!: string;
@Column({ length: 150 })
@Column({ name: 'company_email', type: 'varchar', length: 150 })
companyEmail!: string;
@Column({ length: 20 })
@Column({ name: 'company_phone', type: 'varchar', length: 20 })
companyPhone!: string;
@Column({ length: 100 })
@Column({ name: 'company_location', type: 'varchar', length: 100 })
companyLocation!: string;
@Column({ type: "text" })
@Column({ name: 'company_address', type: 'text' })
companyAddress!: string;
@Column({ length: 100 })
@Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true })
customerType?: string | null;
@Column({ name: 'status', type: 'varchar', length: 32, nullable: true })
status?: string | null;
@Column({ name: 'contact_person_name', type: 'varchar', length: 100 })
contactPersonName!: string;
@Column({ length: 20 })
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20 })
contactPersonPhone!: string;
@Column({ length: 10, unique: true })
@Index()
@Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true })
tinNumber!: string;
@Column({ length: 50, nullable: true })
vatNumber?: string;
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
vatNumber?: string | null;
@Column({ length: 16, unique: true })
@Index()
@Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true })
fanNumber!: string;
@Column({ length: 100 })
@Column({ name: 'general_manager_name', type: 'varchar', length: 100 })
generalManagerName!: string;
@Column({ length: 150 })
@Column({ name: 'general_manager_email', type: 'varchar', length: 150 })
generalManagerEmail!: string;
@Column({ length: 20 })
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20 })
generalManagerPhone!: string;
@Column({ length: 100, nullable: true })
poaName?: string;
@Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true })
poaName?: string | null;
@Column({ length: 20, nullable: true })
poaPhone?: string;
@Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true })
poaPhone?: string | null;
@Column({ type: "text", nullable: true })
poaAddress?: string;
@Column({ name: 'poa_address', type: 'text', nullable: true })
poaAddress?: string | null;
@Column({ nullable: true, length: 150 })
poaEmail?: string;
@Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true })
poaEmail?: string | null;
@Column({ length: 100, nullable: true })
poaLocation?: string;
@Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true })
poaLocation?: string | null;
@Column({ type: "text", nullable: true })
notes?: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -1,57 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
@ApiTags("customers")
@Controller("customers")
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
@ApiOperation({ summary: "Create a new customer" })
create(@Body() dto: CreateCustomerDto) {
return this.customersService.create(dto);
}
@Get()
@ApiOperation({ summary: "List all customers" })
findAll() {
return this.customersService.findAll();
}
@Get(":id")
@ApiOperation({ summary: "Get a customer by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.customersService.findById(id);
}
@Patch(":id")
@ApiOperation({ summary: "Update a customer" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
) {
return this.customersService.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Soft-delete a customer" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.customersService.remove(id);
}
}

View File

@@ -1,15 +0,0 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CustomersController } from "./customers.controller";
import { CustomersRepository } from "./customers.repository";
import { CustomersService } from "./customers.service";
import { Customer } from "./entities/customer.entity";
@Module({
imports: [TypeOrmModule.forFeature([Customer])],
controllers: [CustomersController],
providers: [CustomersService, CustomersRepository],
exports: [CustomersService],
})
export class CustomersModule {}

View File

@@ -1,21 +0,0 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Customer } from "./entities/customer.entity";
@Injectable()
export class CustomersRepository extends BaseRepository<Customer> {
constructor(
@InjectRepository(Customer)
repository: Repository<Customer>,
) {
super(repository);
}
/** Find a customer by their unique email. */
findByEmail(email: string): Promise<Customer | null> {
return this.repository.findOne({ where: { email } });
}
}

View File

@@ -1,61 +0,0 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { CustomersRepository } from "./customers.repository";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
async create(dto: CreateCustomerDto): Promise<Customer> {
const existing = await this.customersRepository.findByEmail(dto.email);
if (existing) {
throw new ConflictException(
`Customer with email "${dto.email}" already exists`,
);
}
return this.customersRepository.create(dto);
}
findAll(): Promise<Customer[]> {
return this.customersRepository.findAll({ order: { name: "ASC" } });
}
async findById(id: string): Promise<Customer> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
throw new NotFoundException(`Customer ${id} not found`);
}
return customer;
}
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
await this.findById(id);
if (dto.email) {
const conflict = await this.customersRepository.findByEmail(dto.email);
if (conflict && conflict.id !== id) {
throw new ConflictException(
`Customer with email "${dto.email}" already exists`,
);
}
}
const updated = await this.customersRepository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Customer ${id} not found`);
}
return updated;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.customersRepository.softDelete(id);
}
}

View File

@@ -1,73 +0,0 @@
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
export enum CustomerStatusDto {
Active = "Active",
Pending = "Pending",
Inactive = "Inactive",
}
export enum CustomerTypeDto {
Importer = "Importer",
Exporter = "Exporter",
Supplier = "Supplier",
}
export class CreateCustomerDto {
@IsString()
@MaxLength(256)
name!: string;
@IsEmail()
email!: string;
@IsString()
@MaxLength(32)
phone!: string;
@IsOptional()
@IsString()
@MaxLength(256)
company?: string;
@IsOptional()
@IsEnum(CustomerTypeDto)
customerType?: CustomerTypeDto;
@IsOptional()
@IsEnum(CustomerStatusDto)
status?: CustomerStatusDto;
@IsOptional()
@IsString()
@MaxLength(64)
tinNumber?: string;
@IsOptional()
@IsString()
@MaxLength(128)
city?: string;
@IsOptional()
@IsString()
@MaxLength(128)
country?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
@MaxLength(64)
taxId?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -1,5 +0,0 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateCustomerDto } from "./create-customer.dto";
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}

View File

@@ -1,54 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
export type CustomerStatus = "Active" | "Pending" | "Inactive";
export type CustomerType = "Importer" | "Exporter" | "Supplier";
@Entity({schema:"freight", name: "customers" })
export class Customer extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 256 })
name!: string;
@Column({ name: "email", type: "varchar", length: 256, unique: true })
email!: string;
@Column({ name: "phone", type: "varchar", length: 32 })
phone!: string;
@Column({ name: "company", type: "varchar", length: 256, nullable: true })
company?: string | null;
@Column({
name: "customer_type",
type: "varchar",
length: 32,
default: "Importer",
})
customerType!: CustomerType;
@Column({
name: "status",
type: "varchar",
length: 32,
default: "Active",
})
status!: CustomerStatus;
@Column({ name: "tin_number", type: "varchar", length: 64, nullable: true })
tinNumber?: string | null;
@Column({ name: "city", type: "varchar", length: 128, nullable: true })
city?: string | null;
@Column({ name: "country", type: "varchar", length: 128, nullable: true })
country?: string | null;
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
taxId?: string | null;
@Column({ name: "notes", type: "text", nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,60 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
import { ApprovalRulesService } from '../services/approval-rules.service';
@ApiTags('approval-rules')
@Controller('approval-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ApprovalRulesController {
constructor(private readonly service: ApprovalRulesService) {}
@Get()
@ApiOperation({ summary: 'List approval rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
requiresDirectorApproval:
query['requiresDirectorApproval'] !== undefined
? query['requiresDirectorApproval'] === 'true'
: undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get('chain')
@ApiOperation({ summary: 'Get approval chain for cargo routing flag' })
findChain(@Query('requiresDirectorApproval') flag: string) {
return this.service.findChain(flag === 'true');
}
@Get(':id')
@ApiOperation({ summary: 'Get an approval rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create an approval rule step' })
create(@Body() dto: CreateApprovalRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update an approval rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete an approval rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -3,7 +3,7 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
// import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoTypesService } from '../services/cargo-types.service';
@@ -39,8 +39,7 @@ export class CargoTypesController {
@Post()
@ApiOperation({ summary: 'Create a cargo type' })
create(@Body() dto: any) {
return dto;
create(@Body() dto: CreateCargoTypeDto) {
return this.service.create(dto);
}

View File

@@ -0,0 +1,70 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { RatesService } from '../services/rates.service';
@ApiTags('rates')
@Controller('rates')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class RatesController {
constructor(private readonly service: RatesService) {}
@Get()
@ApiOperation({ summary: 'List rates' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
status: query['status'],
rateType: query['rateType'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get('live')
@ApiOperation({ summary: 'List all LIVE rates effective now' })
findLive() {
return this.service.findLiveRates();
}
@Get(':id')
@ApiOperation({ summary: 'Get a rate by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
create(@Body() dto: CreateRateDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a DRAFT rate' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
return this.service.update(id, dto);
}
@Post(':id/submit')
@ApiOperation({ summary: 'Submit rate for CEO approval' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.service.submitForApproval(id);
}
@Post(':id/approve')
@ApiOperation({ summary: 'CEO approves a rate' })
approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) {
return this.service.approve(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a rate' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,51 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
import { ShippingLinesService } from '../services/shipping-lines.service';
@ApiTags('shipping-lines')
@Controller('shipping-lines')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ShippingLinesController {
constructor(private readonly service: ShippingLinesService) {}
@Get()
@ApiOperation({ summary: 'List shipping lines' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a shipping line by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a shipping line' })
create(@Body() dto: CreateShippingLineDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a shipping line' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a shipping line' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -18,7 +18,7 @@ export class WeightLimitRulesController {
@ApiOperation({ summary: 'List weight limit rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
tradeDirection: query['tradeDirection'],
containerTypeId: query['containerTypeId'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,

View File

@@ -3,49 +3,49 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateSurchargeDto } from '../dto/create-surcharge.dto';
import { UpdateSurchargeDto } from '../dto/update-surcharge.dto';
import { SurchargesService } from '../services/surcharges.service';
import { CreateYardDto } from '../dto/create-yard.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
import { YardsService } from '../services/yards.service';
@ApiTags('surcharges')
@Controller('surcharges')
@ApiTags('yards')
@Controller('yards')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class SurchargesController {
constructor(private readonly service: SurchargesService) {}
export class YardsController {
constructor(private readonly service: YardsService) {}
@Get()
@ApiOperation({ summary: 'List surcharges' })
@ApiOperation({ summary: 'List yards' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
surchargeTypeId: query['surchargeTypeId'],
country: query['country'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a surcharge by ID' })
@ApiOperation({ summary: 'Get a yard by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a surcharge' })
create(@Body() dto: CreateSurchargeDto) {
@ApiOperation({ summary: 'Create a yard' })
create(@Body() dto: CreateYardDto) {
return this.service.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a surcharge' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeDto) {
@ApiOperation({ summary: 'Update a yard' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a surcharge' })
@ApiOperation({ summary: 'Soft-delete a yard' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
export class CreateApprovalRuleDto {
@ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' })
@IsBoolean()
requiresDirectorApproval!: boolean;
@ApiProperty({ description: 'Step sequence number (1 = first, 2 = second)', minimum: 1 })
@IsInt()
@Min(1)
stepOrder!: number;
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
@IsString()
@MaxLength(30)
requiredRole!: string;
@ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 })
@IsString()
@MaxLength(50)
actionLabel!: string;
@ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' })
@IsOptional()
@IsString()
@MaxLength(30)
blocksRole?: string;
}

View File

@@ -2,11 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateCargoTypeDto {
@ApiProperty({ description: 'Machine-readable code, e.g. BULK, BREAK_BULK', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@IsString()
@MaxLength(255)

View File

@@ -1,25 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateContainerTypeDto {
@ApiProperty({ description: 'Size code, e.g. 20FT or 40FT', maxLength: 20 })
@IsString()
@MaxLength(20)
sizeCode!: string;
@ApiPropertyOptional({ description: 'Human-readable description', maxLength: 100 })
@IsOptional()
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
@IsString()
@MaxLength(100)
description?: string;
label!: string;
@ApiProperty({ description: 'Number of containers that fit per rail wagon (2 for 20FT, 1 for 40FT)' })
@ApiProperty({ description: 'Container size in feet: 20 or 40', enum: [20, 40] })
@IsInt()
@Min(1)
containersPerWagon!: number;
@Min(20)
@Max(40)
sizeFt!: number;
@ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' })
@IsNumber()
@Min(0.01)
@Transform(({ value }) => Number(value))
wagonsPerUnit!: number;
@ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' })
@IsOptional()
@IsBoolean()
isReefer?: boolean;
@ApiPropertyOptional({ default: false, description: 'True if this is an open-top container' })
@IsOptional()
@IsBoolean()
isOpenTop?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number;
}

View File

@@ -1,33 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { Freight } from '@edr/types';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreatePriorityRuleDto {
@ApiProperty({ enum: Freight.PriorityType, description: 'Priority type (unique per rule)' })
@IsEnum(Freight.PriorityType)
priorityType!: Freight.PriorityType;
@ApiProperty({ description: 'Human-readable rule name', maxLength: 255 })
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
@IsString()
@MaxLength(255)
ruleName!: string;
@MaxLength(100)
label!: string;
@ApiPropertyOptional({ description: 'Explanation of when this rule is triggered' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Technical expression describing the activation condition' })
@IsOptional()
@IsString()
activationCondition?: string;
@ApiProperty({ description: 'Points added to booking.priorityScore when this rule matches', default: 0 })
@ApiProperty({ description: 'Points added to booking.priority_score when condition matches', default: 0 })
@IsInt()
@Min(0)
bonusPoints!: number;
score!: number;
@ApiPropertyOptional({ default: false })
@ApiPropertyOptional({
description: 'If set, rule only matches bookings with this payment currency (e.g. USD). Null = matches all.',
maxLength: 5,
})
@IsOptional()
@IsString()
@MaxLength(5)
conditionCurrency?: string;
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })
@IsOptional()
@IsBoolean()
isActive?: boolean;

View File

@@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const CURRENCIES = ['ETB', 'USD'] as const;
export class CreateRateDto {
@ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' })
@IsIn([...RATE_TYPES])
rateType!: string;
@ApiPropertyOptional({ description: 'FK to container_types.id — null for non-container rates' })
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS, description: 'Trade direction. Null = direction-agnostic' })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])
tradeDirection?: string;
@ApiProperty({ enum: CURRENCIES })
@IsIn([...CURRENCIES])
currency!: string;
@ApiProperty({ description: 'Numeric rate value', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
rateValue!: number;
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
@IsIn([...RATE_UNITS])
rateUnit!: string;
@ApiProperty({ description: 'ID of the staff member (Director) proposing this rate' })
@IsUUID()
proposedByStaffId!: string;
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
@IsDateString()
effectiveFrom!: string;
@ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' })
@IsOptional()
@IsDateString()
effectiveTo?: string;
}
export class ApproveRateDto {
@ApiProperty({ description: 'ID of the CEO approving this rate' })
@IsUUID()
approvedByCeoId!: string;
}
export class SubmitRateForApprovalDto {
@ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -2,11 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto {
@ApiProperty({ description: 'Machine-readable code, e.g. RAIL_ONLY', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
@IsString()
@MaxLength(255)

View File

@@ -0,0 +1,36 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateShippingLineDto {
@ApiProperty({ description: 'Unique shipping line code, e.g. MSC, PIL, MAERSK', maxLength: 20 })
@IsString()
@MaxLength(20)
code!: string;
@ApiProperty({ description: 'Customer-facing label', maxLength: 100 })
@IsString()
@MaxLength(100)
label!: string;
@ApiPropertyOptional({
description: 'If set, backend silently uses this code for pricing tier lookups (e.g. PIL → MAERSK)',
maxLength: 20,
})
@IsOptional()
@IsString()
@MaxLength(20)
mappedToCode?: string;
@ApiPropertyOptional({
default: false,
description: 'If true, quotation renders additional fee notice to customer',
})
@IsOptional()
@IsBoolean()
showExtraFeeNotice?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -1,21 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
const TRIGGER_CONDITIONS = [
'CARGO_FLAG_HAZARDOUS',
'CARGO_FLAG_REEFER',
'VGM_EXCEEDS_LIMIT',
'SHIPPING_LINE_MAPPED',
'CONSOLIDATION_ENABLED',
] as const;
export class CreateSurchargeTypeDto {
@ApiProperty({ description: 'Unique code, e.g. HAZARDOUS, REFRIGERATED', maxLength: 50 })
@IsString()
@MaxLength(50)
code!: string;
@ApiProperty({ description: 'Display name', maxLength: 100 })
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
@IsString()
@MaxLength(100)
name!: string;
label!: string;
@ApiPropertyOptional({ description: 'Description of when this surcharge type is triggered' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ enum: TRIGGER_CONDITIONS, description: 'Condition that auto-fires this surcharge' })
@IsIn([...TRIGGER_CONDITIONS])
triggerCondition!: string;
@ApiProperty({ description: 'FK to rates.id — the LIVE rate used to price this surcharge' })
@IsUUID()
rateId!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()

View File

@@ -1,63 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
Length,
MaxLength,
Min,
} from 'class-validator';
import { Freight } from '@edr/types';
export class CreateSurchargeDto {
@ApiProperty({ description: 'FK to surcharge_types.id' })
@IsUUID()
surchargeTypeId!: string;
@ApiProperty({ description: 'Display name for this surcharge line item', maxLength: 255 })
@IsString()
@MaxLength(255)
feeName!: string;
@ApiPropertyOptional({ description: 'Human-readable description of when this surcharge is triggered' })
@IsOptional()
@IsString()
triggerDescription?: string;
@ApiProperty({ enum: Freight.CalculationMethod, default: Freight.CalculationMethod.PER_TON })
@IsEnum(Freight.CalculationMethod)
calculationMethod!: Freight.CalculationMethod;
@ApiProperty({ description: 'Rate amount (per ton, flat, or percentage)' })
@IsNumber()
@Min(0)
rate!: number;
@ApiProperty({ description: 'ISO 4217 currency code', default: 'USD' })
@IsString()
@Length(3, 3)
currency!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToRail?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToFirstMile?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
applyToLastMile?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -1,38 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { Freight } from '@edr/types';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
export class CreateWeightLimitRuleDto {
@ApiProperty({ description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ enum: Freight.TradeDirection, description: 'Trade direction this rule applies to' })
@IsEnum(Freight.TradeDirection)
tradeDirection!: Freight.TradeDirection;
@ApiProperty({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or BOTH' })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ description: 'Maximum allowed weight in tons before surcharge is applied' })
@ApiProperty({ description: 'Maximum allowed VGM in tons', minimum: 0 })
@IsNumber()
@Min(0)
maxWeightTons!: number;
@Transform(({ value }) => Number(value))
maxVgmTons!: number;
@ApiProperty({ description: 'Weight at which a warning is issued (must be ≤ maxWeightTons)' })
@IsNumber()
@Min(0)
warningThresholdTons!: number;
@ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' })
@IsDateString()
effectiveFrom!: string;
@ApiPropertyOptional({ enum: Freight.ExceededAction, default: Freight.ExceededAction.WARNING_ONLY })
@ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' })
@IsOptional()
@IsEnum(Freight.ExceededAction)
exceededAction?: Freight.ExceededAction;
@ApiPropertyOptional({ description: 'FK to surcharges.id — surcharge billed when max is exceeded' })
@IsOptional()
@IsUUID()
surchargeId?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsDateString()
effectiveTo?: string;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@IsString()
@MaxLength(100)
label!: string;
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 })
@IsString()
@MaxLength(50)
country!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateApprovalRuleDto } from './create-approval-rule.dto';
export class UpdateApprovalRuleDto extends PartialType(CreateApprovalRuleDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateRateDto } from './create-rate.dto';
export class UpdateRateDto extends PartialType(CreateRateDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateShippingLineDto } from './create-shipping-line.dto';
export class UpdateShippingLineDto extends PartialType(CreateShippingLineDto) {}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSurchargeDto } from './create-surcharge.dto';
export class UpdateSurchargeDto extends PartialType(CreateSurchargeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateYardDto } from './create-yard.dto';
export class UpdateYardDto extends PartialType(CreateYardDto) {}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, Unique } from 'typeorm';
@Entity({ schema: 'freight', name: 'approval_rules' })
@Unique(['requiresDirectorApproval', 'stepOrder'])
@Index(['requiresDirectorApproval'])
@Index(['stepOrder'])
export class ApprovalRule extends BaseEntity {
@Column({ name: 'requires_director_approval', type: 'boolean' })
requiresDirectorApproval!: boolean;
@Column({ name: 'step_order', type: 'smallint' })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 30 })
requiredRole!: string;
@Column({ name: 'action_label', type: 'varchar', length: 50 })
actionLabel!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
blocksRole?: string | null;
}

View File

@@ -3,21 +3,33 @@ import { Column, Entity, Index, OneToMany } from 'typeorm';
import { WeightLimitRule } from './weight-limit-rule.entity';
@Entity({ schema: 'freight', name: 'container_types' })
@Index(['sizeCode'])
@Index(['code'])
@Index(['isActive'])
export class ContainerType extends BaseEntity {
@Column({ name: 'size_code', type: 'varchar', length: 20, unique: true })
sizeCode!: string;
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
code!: string;
@Column({ name: 'description', type: 'varchar', length: 100, nullable: true })
description?: string | null;
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
label!: string;
@Column({ name: 'containers_per_wagon', type: 'int' })
containersPerWagon!: number;
@Column({ name: 'size_ft', type: 'smallint', nullable: true })
sizeFt!: number;
@Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true })
wagonsPerUnit!: number;
@Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true })
isReefer!: boolean;
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
isOpenTop!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'display_order', type: 'int', default: 1, nullable: true })
displayOrder!: number;
@OneToMany(() => WeightLimitRule, (rule) => rule.containerType)
weightLimitRules?: WeightLimitRule[];
}

View File

@@ -1,25 +1,21 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'priority_rules' })
@Index(['priorityType'])
@Index(['code'])
@Index(['isActive'])
export class PriorityRule extends BaseEntity {
@Column({ name: 'priority_type', type: 'enum', enum: Freight.PriorityType, unique: true })
priorityType!: Freight.PriorityType;
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
code!: string;
@Column({ name: 'rule_name', type: 'varchar', length: 255 })
ruleName!: string;
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
label!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'score', type: 'int', default: 0, nullable: true })
score!: number;
@Column({ name: 'activation_condition', type: 'text', nullable: true })
activationCondition?: string | null;
@Column({ name: 'bonus_points', type: 'int', default: 0 })
bonusPoints!: number;
@Column({ name: 'condition_currency', type: 'varchar', length: 5, nullable: true })
conditionCurrency?: string | null;
@Column({ name: 'is_active', type: 'boolean', default: false })
isActive!: boolean;

View File

@@ -0,0 +1,78 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from './container-type.entity';
export const RATE_TYPES = [
'CONTAINER_IMPORT',
'CONTAINER_EXPORT',
'BULK_IMPORT',
'BULK_EXPORT',
'INTERCITY_BULK',
'INTERCITY_CONTAINER',
'FIRST_MILE',
'LAST_MILE',
'DEMURRAGE',
'LASHING',
'DOUBLE_HANDLING',
'CONTAINER_WITH_RETURN',
'CANCELLATION_FEE',
'OVERWEIGHT_PER_TON',
'HAZARD_SURCHARGE',
'REEFER_SURCHARGE',
'PIL_EXTRA_FEE',
] as const;
export type RateType = typeof RATE_TYPES[number];
export const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const;
export type RateStatus = typeof RATE_STATUSES[number];
export const RATE_UNITS = ['PER_WAGON', 'PER_TON', 'PER_CONTAINER', 'PER_KM', 'FLAT'] as const;
export type RateUnit = typeof RATE_UNITS[number];
@Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType'])
@Index(['status'])
@Index(['effectiveFrom'])
@Index(['containerTypeId'])
export class Rate extends BaseEntity {
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
rateType!: RateType;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true, eager: false })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
tradeDirection?: string | null;
@Column({ name: 'currency', type: 'varchar', length: 5 })
currency!: string;
@Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 })
rateValue!: number;
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
rateUnit!: RateUnit;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: RateStatus;
@Column({ name: 'proposed_by_staff_id', type: 'uuid' })
proposedByStaffId!: string;
@Column({ name: 'approved_by_ceo_id', type: 'uuid', nullable: true })
approvedByCeoId?: string | null;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt?: Date | null;
@Column({ name: 'effective_from', type: 'date' })
effectiveFrom!: Date;
@Column({ name: 'effective_to', type: 'date', nullable: true })
effectiveTo?: Date | null;
}

View File

@@ -0,0 +1,22 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'shipping_lines' })
@Index(['code'])
@Index(['isActive'])
export class ShippingLine extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
code!: string;
@Column({ name: 'label', type: 'varchar', length: 100 })
label!: string;
@Column({ name: 'mapped_to_code', type: 'varchar', length: 20, nullable: true })
mappedToCode?: string | null;
@Column({ name: 'show_extra_fee_notice', type: 'boolean', default: false })
showExtraFeeNotice!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -1,23 +1,38 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Surcharge } from './surcharge.entity';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Rate } from './rate.entity';
const TRIGGER_CONDITIONS = [
'CARGO_FLAG_HAZARDOUS',
'CARGO_FLAG_REEFER',
'VGM_EXCEEDS_LIMIT',
'SHIPPING_LINE_MAPPED',
'CONSOLIDATION_ENABLED',
] as const;
export type TriggerCondition = typeof TRIGGER_CONDITIONS[number];
@Entity({ schema: 'freight', name: 'surcharge_types' })
@Index(['code'])
@Index(['isActive'])
@Index(['rateId'])
export class SurchargeType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true })
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
label!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'trigger_condition', type: 'varchar', length: 50, nullable: true })
triggerCondition!: TriggerCondition;
@Column({ name: 'rate_id', type: 'uuid', nullable: true })
rateId!: string;
@ManyToOne(() => Rate, { eager: false })
@JoinColumn({ name: 'rate_id' })
rate?: Rate;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => Surcharge, (s) => s.surchargeType)
surcharges?: Surcharge[];
}

View File

@@ -1,52 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { SurchargeType } from './surcharge-type.entity';
import { WeightLimitRule } from './weight-limit-rule.entity';
@Entity({ schema: 'freight', name: 'surcharges' })
@Index(['surchargeTypeId'])
@Index(['isActive'])
export class Surcharge extends BaseEntity {
@Column({ name: 'surcharge_type_id', type: 'uuid' })
surchargeTypeId!: string;
@ManyToOne(() => SurchargeType, (st) => st.surcharges)
@JoinColumn({ name: 'surcharge_type_id' })
surchargeType!: SurchargeType;
@Column({ name: 'fee_name', type: 'varchar', length: 255 })
feeName!: string;
@Column({ name: 'trigger_description', type: 'text', nullable: true })
triggerDescription?: string | null;
@Column({
name: 'calculation_method',
type: 'enum',
enum: Freight.CalculationMethod,
default: Freight.CalculationMethod.PER_TON,
})
calculationMethod!: Freight.CalculationMethod;
@Column({ name: 'rate', type: 'numeric', precision: 10, scale: 2 })
rate!: number;
@Column({ name: 'currency', type: 'char', length: 3, default: 'USD' })
currency!: string;
@Column({ name: 'apply_to_rail', type: 'boolean', default: false })
applyToRail!: boolean;
@Column({ name: 'apply_to_first_mile', type: 'boolean', default: false })
applyToFirstMile!: boolean;
@Column({ name: 'apply_to_last_mile', type: 'boolean', default: false })
applyToLastMile!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => WeightLimitRule, (rule) => rule.surcharge)
weightLimitRules?: WeightLimitRule[];
}

View File

@@ -1,13 +1,11 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from './container-type.entity';
import { Surcharge } from './surcharge.entity';
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
@Index(['containerTypeId'])
@Index(['surchargeId'])
@Index(['isActive'])
@Index(['tradeDirection'])
@Index(['effectiveFrom'])
export class WeightLimitRule extends BaseEntity {
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@@ -16,30 +14,15 @@ export class WeightLimitRule extends BaseEntity {
@JoinColumn({ name: 'container_type_id' })
containerType!: ContainerType;
@Column({ name: 'trade_direction', type: 'enum', enum: Freight.TradeDirection })
tradeDirection!: Freight.TradeDirection;
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
@Column({ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2 })
maxWeightTons!: number;
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxVgmTons!: number;
@Column({ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2 })
warningThresholdTons!: number;
@Column({ name: 'effective_from', type: 'date', nullable: true })
effectiveFrom!: Date;
@Column({
name: 'exceeded_action',
type: 'enum',
enum: Freight.ExceededAction,
default: Freight.ExceededAction.WARNING_ONLY,
})
exceededAction!: Freight.ExceededAction;
@Column({ name: 'surcharge_id', type: 'uuid', nullable: true })
surchargeId?: string | null;
@ManyToOne(() => Surcharge, (s) => s.weightLimitRules, { nullable: true })
@JoinColumn({ name: 'surcharge_id' })
surcharge?: Surcharge | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'effective_to', type: 'date', nullable: true })
effectiveTo?: Date | null;
}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'yards' })
@Index(['code'])
@Index(['country'])
@Index(['isActive'])
export class Yard extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
code!: string;
@Column({ name: 'label', type: 'varchar', length: 100 })
label!: string;
@Column({ name: 'country', type: 'varchar', length: 50 })
country!: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'display_order', type: 'int', default: 1 })
displayOrder!: number;
}

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { ApprovalRule } from '../entities/approval-rule.entity';
export interface IApprovalRulesRepository {
findById(id: string): Promise<ApprovalRule | null>;
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>;
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>;
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>;
create(data: Partial<ApprovalRule>): Promise<ApprovalRule>;
update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>;
softDelete(id: string): Promise<void>;
}
export const APPROVAL_RULES_REPOSITORY = Symbol('APPROVAL_RULES_REPOSITORY');

View File

@@ -3,7 +3,7 @@ import { ContainerType } from '../entities/container-type.entity';
export interface IContainerTypesRepository {
findById(id: string): Promise<ContainerType | null>;
findBySizeCode(sizeCode: string): Promise<ContainerType | null>;
findByCode(code: string): Promise<ContainerType | null>;
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
create(data: Partial<ContainerType>): Promise<ContainerType>;

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { Rate } from '../entities/rate.entity';
export interface IRatesRepository {
findById(id: string): Promise<Rate | null>;
findLiveRates(): Promise<Rate[]>;
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
create(data: Partial<Rate>): Promise<Rate>;
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
softDelete(id: string): Promise<void>;
}
export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY');

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { ShippingLine } from '../entities/shipping-line.entity';
export interface IShippingLinesRepository {
findById(id: string): Promise<ShippingLine | null>;
findByCode(code: string): Promise<ShippingLine | null>;
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>;
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>;
create(data: Partial<ShippingLine>): Promise<ShippingLine>;
update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>;
softDelete(id: string): Promise<void>;
}
export const SHIPPING_LINES_REPOSITORY = Symbol('SHIPPING_LINES_REPOSITORY');

View File

@@ -4,6 +4,7 @@ import { SurchargeType } from '../entities/surcharge-type.entity';
export interface ISurchargeTypesRepository {
findById(id: string): Promise<SurchargeType | null>;
findByCode(code: string): Promise<SurchargeType | null>;
findAllActiveWithRate(): Promise<SurchargeType[]>;
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]>;
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]>;
create(data: Partial<SurchargeType>): Promise<SurchargeType>;

View File

@@ -1,14 +0,0 @@
import { FindManyOptions } from 'typeorm';
import { Surcharge } from '../entities/surcharge.entity';
export interface ISurchargesRepository {
findById(id: string): Promise<Surcharge | null>;
findByTypeCode(typeCode: string): Promise<Surcharge | null>;
findAll(options?: FindManyOptions<Surcharge>): Promise<Surcharge[]>;
findAndCount(options?: FindManyOptions<Surcharge>): Promise<[Surcharge[], number]>;
create(data: Partial<Surcharge>): Promise<Surcharge>;
update(id: string, data: Partial<Surcharge>): Promise<Surcharge | null>;
softDelete(id: string): Promise<void>;
}
export const SURCHARGES_REPOSITORY = Symbol('SURCHARGES_REPOSITORY');

View File

@@ -3,8 +3,8 @@ import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
export interface IWeightLimitRulesRepository {
findById(id: string): Promise<WeightLimitRule | null>;
findActiveByContainerTypeAndDirection(
sizeCode: string,
findActiveByContainerTypeId(
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from 'typeorm';
import { Yard } from '../entities/yard.entity';
export interface IYardsRepository {
findById(id: string): Promise<Yard | null>;
findByCode(code: string): Promise<Yard | null>;
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
create(data: Partial<Yard>): Promise<Yard>;
update(id: string, data: Partial<Yard>): Promise<Yard | null>;
softDelete(id: string): Promise<void>;
}
export const YARDS_REPOSITORY = Symbol('YARDS_REPOSITORY');

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { ApprovalRule } from '../entities/approval-rule.entity';
import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface';
@Injectable()
export class ApprovalRulesRepository implements IApprovalRulesRepository {
private readonly repo: Repository<ApprovalRule>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(ApprovalRule);
}
findById(id: string): Promise<ApprovalRule | null> {
return this.repo.findOne({ where: { id } });
}
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]> {
return this.repo.find({
where: { requiresDirectorApproval },
order: { stepOrder: 'ASC' },
});
}
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<ApprovalRule>): Promise<ApprovalRule> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -15,8 +15,8 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
return this.repo.findOne({ where: { id } });
}
findBySizeCode(sizeCode: string): Promise<ContainerType | null> {
return this.repo.findOne({ where: { sizeCode } });
findByCode(code: string): Promise<ContainerType | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]> {

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { Rate } from '../entities/rate.entity';
import { IRatesRepository } from '../interfaces/rates.repository.interface';
@Injectable()
export class RatesRepository implements IRatesRepository {
private readonly repo: Repository<Rate>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(Rate);
}
findById(id: string): Promise<Rate | null> {
return this.repo.findOne({ where: { id } });
}
findLiveRates(): Promise<Rate[]> {
const now = new Date();
return this.repo
.createQueryBuilder('rate')
.where('rate.status = :status', { status: 'LIVE' })
.andWhere('rate.effective_from <= :now', { now })
.andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now })
.getMany();
}
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<Rate>): Promise<Rate> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<Rate>): Promise<Rate | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { ShippingLine } from '../entities/shipping-line.entity';
import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface';
@Injectable()
export class ShippingLinesRepository implements IShippingLinesRepository {
private readonly repo: Repository<ShippingLine>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(ShippingLine);
}
findById(id: string): Promise<ShippingLine | null> {
return this.repo.findOne({ where: { id } });
}
findByCode(code: string): Promise<ShippingLine | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<ShippingLine>): Promise<ShippingLine> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -19,6 +19,13 @@ export class SurchargeTypesRepository implements ISurchargeTypesRepository {
return this.repo.findOne({ where: { code } });
}
findAllActiveWithRate(): Promise<SurchargeType[]> {
return this.repo.find({
where: { isActive: true },
relations: { rate: true },
});
}
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]> {
return this.repo.find(options);
}

View File

@@ -1,46 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { Surcharge } from '../entities/surcharge.entity';
import { ISurchargesRepository } from '../interfaces/surcharges.repository.interface';
@Injectable()
export class SurchargesRepository implements ISurchargesRepository {
private readonly repo: Repository<Surcharge>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(Surcharge);
}
findById(id: string): Promise<Surcharge | null> {
return this.repo.findOne({ where: { id }, relations: { surchargeType: true } });
}
findByTypeCode(typeCode: string): Promise<Surcharge | null> {
return this.repo.findOne({
where: { isActive: true, surchargeType: { code: typeCode } },
relations: { surchargeType: true },
});
}
findAll(options?: FindManyOptions<Surcharge>): Promise<Surcharge[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<Surcharge>): Promise<[Surcharge[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<Surcharge>): Promise<Surcharge> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<Surcharge>): Promise<Surcharge | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -14,25 +14,25 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
findById(id: string): Promise<WeightLimitRule | null> {
return this.repo.findOne({
where: { id },
relations: { containerType: true, surcharge: { surchargeType: true } },
relations: { containerType: true },
});
}
findActiveByContainerTypeAndDirection(
sizeCode: string,
findActiveByContainerTypeId(
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]> {
const now = new Date();
return this.repo
.createQueryBuilder('rule')
.innerJoinAndSelect('rule.containerType', 'ct')
.leftJoinAndSelect('rule.surcharge', 'surcharge')
.leftJoinAndSelect('surcharge.surchargeType', 'surchargeType')
.where('ct.size_code = :sizeCode', { sizeCode })
.where('rule.container_type_id = :containerTypeId', { containerTypeId })
.andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', {
dir: tradeDirection,
both: 'BOTH',
})
.andWhere('rule.is_active = true')
.andWhere('rule.effective_from <= :now', { now })
.andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now })
.getMany();
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { Yard } from '../entities/yard.entity';
import { IYardsRepository } from '../interfaces/yards.repository.interface';
@Injectable()
export class YardsRepository implements IYardsRepository {
private readonly repo: Repository<Yard>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(Yard);
}
findById(id: string): Promise<Yard | null> {
return this.repo.findOne({ where: { id } });
}
findByCode(code: string): Promise<Yard | null> {
return this.repo.findOne({ where: { code } });
}
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<Yard>): Promise<Yard> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<Yard>): Promise<Yard | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -1,48 +1,68 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityRule } from './entities/priority-rule.entity';
import { Surcharge } from './entities/surcharge.entity';
import { SurchargeType } from './entities/surcharge-type.entity';
import { ServiceType } from './entities/service-type.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface';
import { PRIORITY_RULES_REPOSITORY } from './interfaces/priority-rules.repository.interface';
import { SURCHARGES_REPOSITORY } from './interfaces/surcharges.repository.interface';
import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface';
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
import { CargoTypesRepository } from './repositories/cargo-types.repository';
import { ContainerTypesRepository } from './repositories/container-types.repository';
import { PriorityRulesRepository } from './repositories/priority-rules.repository';
import { SurchargesRepository } from './repositories/surcharges.repository';
import { SurchargeTypesRepository } from './repositories/surcharge-types.repository';
import { ServiceTypesRepository } from './repositories/service-types.repository';
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityRulesService } from './services/priority-rules.service';
import { SurchargesService } from './services/surcharges.service';
import { SurchargeTypesService } from './services/surcharge-types.service';
import { ServiceTypesService } from './services/service-types.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { ApprovalRulesController } from './controllers/approval-rules.controller';
import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityRulesController } from './controllers/priority-rules.controller';
import { SurchargesController } from './controllers/surcharges.controller';
import { SurchargeTypesController } from './controllers/surcharge-types.controller';
import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
import { SurchargeTypesController } from './controllers/surcharge-types.controller';
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { YardsController } from './controllers/yards.controller';
import { ApprovalRule } from './entities/approval-rule.entity';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityRule } from './entities/priority-rule.entity';
import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
import { SurchargeType } from './entities/surcharge-type.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { Yard } from './entities/yard.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface';
import { PRIORITY_RULES_REPOSITORY } from './interfaces/priority-rules.repository.interface';
import { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface';
import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface';
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface';
import { ApprovalRulesRepository } from './repositories/approval-rules.repository';
import { CargoTypesRepository } from './repositories/cargo-types.repository';
import { ContainerTypesRepository } from './repositories/container-types.repository';
import { PriorityRulesRepository } from './repositories/priority-rules.repository';
import { RatesRepository } from './repositories/rates.repository';
import { ServiceTypesRepository } from './repositories/service-types.repository';
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
import { SurchargeTypesRepository } from './repositories/surcharge-types.repository';
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
import { YardsRepository } from './repositories/yards.repository';
import { ApprovalRulesService } from './services/approval-rules.service';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityRulesService } from './services/priority-rules.service';
import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
import { SurchargeTypesService } from './services/surcharge-types.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { YardsService } from './services/yards.service';
import { RuleEngineService } from './rule-engine.service';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
@Global()
@Module({
imports: [
@@ -50,46 +70,62 @@ import { RuleEngineService } from './rule-engine.service';
CargoType,
ContainerType,
PriorityRule,
Surcharge,
SurchargeType,
ServiceType,
WeightLimitRule,
Yard,
ShippingLine,
Rate,
ApprovalRule,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
]),
],
controllers: [
CargoTypesController,
ContainerTypesController,
PriorityRulesController,
SurchargesController,
SurchargeTypesController,
ServiceTypesController,
WeightLimitRulesController,
YardsController,
ShippingLinesController,
RatesController,
ApprovalRulesController,
],
providers: [
// Repositories
CargoTypesRepository,
{ provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository },
ContainerTypesRepository,
{ provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository },
PriorityRulesRepository,
{ provide: PRIORITY_RULES_REPOSITORY, useExisting: PriorityRulesRepository },
SurchargesRepository,
{ provide: SURCHARGES_REPOSITORY, useExisting: SurchargesRepository },
SurchargeTypesRepository,
{ provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository },
ServiceTypesRepository,
{ provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository },
WeightLimitRulesRepository,
{ provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository },
// CRUD services
YardsRepository,
{ provide: YARDS_REPOSITORY, useExisting: YardsRepository },
ShippingLinesRepository,
{ provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository },
RatesRepository,
{ provide: RATES_REPOSITORY, useExisting: RatesRepository },
ApprovalRulesRepository,
{ provide: APPROVAL_RULES_REPOSITORY, useExisting: ApprovalRulesRepository },
CargoTypesService,
ContainerTypesService,
PriorityRulesService,
SurchargesService,
SurchargeTypesService,
ServiceTypesService,
WeightLimitRulesService,
// Evaluation engine
YardsService,
ShippingLinesService,
RatesService,
ApprovalRulesService,
RuleEngineService,
],
exports: [
@@ -98,9 +134,17 @@ import { RuleEngineService } from './rule-engine.service';
ServiceTypesService,
ContainerTypesService,
SurchargeTypesService,
SurchargesService,
WeightLimitRulesService,
PriorityRulesService,
YardsService,
ShippingLinesService,
RatesService,
ApprovalRulesService,
CARGO_TYPES_REPOSITORY,
CONTAINER_TYPES_REPOSITORY,
SERVICE_TYPES_REPOSITORY,
SHIPPING_LINES_REPOSITORY,
YARDS_REPOSITORY,
],
})
export class RuleEngineModule {}

View File

@@ -1,6 +1,8 @@
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { DataSource } from 'typeorm';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
import { TriggerCondition } from './entities/surcharge-type.entity';
import {
ICargoTypesRepository,
CARGO_TYPES_REPOSITORY,
@@ -9,10 +11,6 @@ import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from './interfaces/service-types.repository.interface';
import {
ISurchargesRepository,
SURCHARGES_REPOSITORY,
} from './interfaces/surcharges.repository.interface';
import {
IWeightLimitRulesRepository,
WEIGHT_LIMIT_RULES_REPOSITORY,
@@ -21,20 +19,64 @@ import {
IPriorityRulesRepository,
PRIORITY_RULES_REPOSITORY,
} from './interfaces/priority-rules.repository.interface';
import {
ISurchargeTypesRepository,
SURCHARGE_TYPES_REPOSITORY,
} from './interfaces/surcharge-types.repository.interface';
import {
IRatesRepository,
RATES_REPOSITORY,
} from './interfaces/rates.repository.interface';
import {
IApprovalRulesRepository,
APPROVAL_RULES_REPOSITORY,
} from './interfaces/approval-rules.repository.interface';
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
export interface AppliedSurcharge {
feeName: string;
rate: number;
export interface BookingContainerEvalInput {
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
totalVgmTons: number;
isReefer?: boolean;
isOverweight?: boolean;
overweightExcessTons?: number | null;
}
export interface BookingEvaluationInput {
cargoTypeId: string;
serviceTypeId: string;
paymentCurrency: string;
tradeDirection: string;
isHazardous: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: BookingContainerEvalInput[];
}
export interface AppliedCargoModifier {
surchargeTypeId: string;
surchargeTypeCode: string;
triggerValue: number | null;
calculatedAmount: number;
rateId: string;
currency: string;
calculationMethod: Freight.CalculationMethod;
applyToRail: boolean;
applyToFirstMile: boolean;
applyToLastMile: boolean;
}
export interface ContainerWeightResult {
containerTypeId: string;
weightLimitRuleId: string | null;
isOverweight: boolean;
overweightExcessTons: number | null;
}
export interface RuleEvaluationResult {
priorityScore: number;
appliedSurcharges: AppliedSurcharge[];
appliedModifiers: AppliedCargoModifier[];
containerWeightResults: ContainerWeightResult[];
warnings: string[];
hardBlocked: string[];
requiresDirectorApproval: boolean;
@@ -47,150 +89,233 @@ export class RuleEngineService {
private readonly cargoTypesRepo: ICargoTypesRepository,
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly serviceTypesRepo: IServiceTypesRepository,
@Inject(SURCHARGES_REPOSITORY)
private readonly surchargesRepo: ISurchargesRepository,
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
@Inject(PRIORITY_RULES_REPOSITORY)
private readonly priorityRulesRepo: IPriorityRulesRepository,
@Inject(SURCHARGE_TYPES_REPOSITORY)
private readonly surchargeTypesRepo: ISurchargeTypesRepository,
@Inject(RATES_REPOSITORY)
private readonly ratesRepo: IRatesRepository,
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly approvalRulesRepo: IApprovalRulesRepository,
@Inject(SHIPPING_LINES_REPOSITORY)
private readonly shippingLinesRepo: IShippingLinesRepository,
private readonly dataSource: DataSource,
) {}
/**
* Evaluate all rule engine rules against a booking snapshot.
* Returns the computed priority score, surcharges to apply, warnings,
* hard-block messages, and whether director approval is required.
* Callers must throw BadRequestException if hardBlocked is non-empty.
*/
async evaluate(
booking: Pick<
Booking,
| 'freightType'
| 'serviceType'
| 'paymentCurrency'
| 'cargoTotalWeightVgm'
| 'tradeDirection'
| 'isHazardous'
| 'isRefrigerated'
| 'containers'
>,
): Promise<RuleEvaluationResult> {
async evaluate(input: BookingEvaluationInput): Promise<RuleEvaluationResult> {
const warnings: string[] = [];
const hardBlocked: string[] = [];
const appliedSurcharges: AppliedSurcharge[] = [];
const appliedModifiers: AppliedCargoModifier[] = [];
const containerWeightResults: ContainerWeightResult[] = [];
let priorityScore = 0;
let requiresDirectorApproval = false;
// ── 1. Cargo routing ─────────────────────────────────────────────────
// Look up CargoType by code to determine director-approval routing.
if (booking.freightType) {
const cargoType = await this.cargoTypesRepo.findByCode(booking.freightType);
if (cargoType?.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) {
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
} else if (cargoType.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
// ── 2. Weight-limit check ────────────────────────────────────────────
// For each container group in the booking, find matching active rules
// and check whether the per-container VGM exceeds the max weight.
const containers = booking.containers ?? [];
for (const container of containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeAndDirection(
container.type,
booking.tradeDirection,
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
input.tradeDirection,
);
const rule = rules[0];
let isOverweight = container.isOverweight ?? false;
let excess = container.overweightExcessTons ?? null;
for (const rule of rules) {
if (container.vgm > rule.maxWeightTons) {
const msg =
`${container.type} container VGM ${container.vgm}t exceeds max ` +
`${rule.maxWeightTons}t (${booking.tradeDirection})`;
if (rule.exceededAction === Freight.ExceededAction.HARD_BLOCK) {
hardBlocked.push(msg);
} else {
warnings.push(msg);
}
if (rule.surcharge) {
appliedSurcharges.push(this.mapSurcharge(rule.surcharge));
}
} else if (container.vgm > rule.warningThresholdTons) {
if (rule) {
const maxTotal = Number(rule.maxVgmTons) * container.quantity;
const totalVgm = container.totalVgmTons;
if (totalVgm > maxTotal) {
isOverweight = true;
excess = Math.max(0, totalVgm - maxTotal);
warnings.push(
`${container.type} container VGM ${container.vgm}t is approaching limit ` +
`of ${rule.maxWeightTons}t (${booking.tradeDirection})`,
`Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`,
);
}
containerWeightResults.push({
containerTypeId: container.containerTypeId,
weightLimitRuleId: rule.id,
isOverweight,
overweightExcessTons: excess,
});
} else {
containerWeightResults.push({
containerTypeId: container.containerTypeId,
weightLimitRuleId: null,
isOverweight,
overweightExcessTons: excess,
});
}
}
// ── 3. Surcharge flags ───────────────────────────────────────────────
if (booking.isHazardous) {
const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS');
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
if (serviceType) {
priorityScore += serviceType.priorityBonusPoints;
}
if (booking.isRefrigerated) {
const surcharge = await this.surchargesRepo.findByTypeCode('REFRIGERATED');
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
}
// ── 4. Priority scoring ──────────────────────────────────────────────
const priorityRules = await this.priorityRulesRepo.findAllActive();
for (const rule of priorityRules) {
switch (rule.priorityType) {
case Freight.PriorityType.USD_PAYER:
if (booking.paymentCurrency === 'USD') {
priorityScore += rule.bonusPoints;
}
break;
case Freight.PriorityType.RAIL_AND_FORWARDING: {
// Read bonus points from the matching ServiceType DB row
const serviceType = await this.serviceTypesRepo.findByCode(booking.serviceType);
if (serviceType && serviceType.priorityBonusPoints > 0) {
priorityScore += serviceType.priorityBonusPoints;
} else if (booking.serviceType === 'RAIL_AND_FORWARDING') {
// Fall back to the rule's own bonus_points if no ServiceType found
priorityScore += rule.bonusPoints;
}
break;
}
case Freight.PriorityType.HIGH_VOLUME_SHIPMENT:
if (booking.cargoTotalWeightVgm >= 300) {
priorityScore += rule.bonusPoints;
}
break;
case Freight.PriorityType.GOVERNMENT_ACCOUNT:
// TODO: integrate customer accountTier — evaluate when Customer entity is extended
break;
if (
rule.conditionCurrency === null ||
rule.conditionCurrency === input.paymentCurrency
) {
priorityScore += rule.score;
}
}
return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval };
let shippingLineMapped = false;
if (input.shippingLineId) {
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
shippingLineMapped = Boolean(line?.mappedToCode);
}
const hasReefer = input.containers.some((c) => c.isReefer);
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate();
const liveRates = await this.ratesRepo.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
for (const st of surchargeTypes) {
const triggered = this.matchesTrigger(st.triggerCondition, {
isHazardous: input.isHazardous,
hasReefer,
hasOverweight,
shippingLineMapped,
allowConsolidation: input.allowConsolidation ?? false,
});
if (!triggered) continue;
const rate = st.rate ?? rateById.get(st.rateId);
if (!rate) continue;
let triggerValue: number | null = null;
let calculatedAmount = Number(rate.rateValue);
if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') {
triggerValue = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
if (rate.rateUnit === 'PER_TON') {
calculatedAmount = triggerValue * Number(rate.rateValue);
}
}
appliedModifiers.push({
surchargeTypeId: st.id,
surchargeTypeCode: st.code,
triggerValue,
calculatedAmount,
rateId: rate.id,
currency: rate.currency,
});
}
return {
priorityScore,
appliedModifiers,
containerWeightResults,
warnings,
hardBlocked,
requiresDirectorApproval,
};
}
/**
* Guard helper — throws BadRequestException if hardBlocked is non-empty.
* Call this immediately after evaluate() in BookingsService.
* Instantiate booking_approval_step rows from approval_rules for a cargo type.
*/
async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise<BookingApprovalStep[]> {
const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
if (!cargoType) {
throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
}
const chain = await this.approvalRulesRepo.findChainForCargo(
cargoType.requiresDirectorApproval,
);
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
const steps: BookingApprovalStep[] = [];
for (const rule of chain) {
const step = stepRepo.create({
bookingId,
approvalRuleId: rule.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
status: 'PENDING',
});
steps.push(await stepRepo.save(step));
}
return steps;
}
/**
* Snapshot all LIVE rates into booking_rate_snapshot for a booking.
*/
async snapshotLiveRates(bookingId: string): Promise<BookingRateSnapshot[]> {
const liveRates = await this.ratesRepo.findLiveRates();
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
const now = new Date();
const snapshots: BookingRateSnapshot[] = [];
for (const rate of liveRates) {
const snapshot = snapshotRepo.create({
bookingId,
rateId: rate.id,
rateType: rate.rateType,
rateValue: rate.rateValue,
rateUnit: rate.rateUnit,
currency: rate.currency,
snapshottedAt: now,
});
snapshots.push(await snapshotRepo.save(snapshot));
}
return snapshots;
}
/** Guard helper — throws BadRequestException if hardBlocked is non-empty. */
assertNoHardBlocks(result: RuleEvaluationResult): void {
if (result.hardBlocked.length > 0) {
throw new BadRequestException(result.hardBlocked.join('; '));
}
}
private mapSurcharge(s: { feeName: string; rate: number; currency: string; calculationMethod: Freight.CalculationMethod; applyToRail: boolean; applyToFirstMile: boolean; applyToLastMile: boolean }): AppliedSurcharge {
return {
feeName: s.feeName,
rate: s.rate,
currency: s.currency,
calculationMethod: s.calculationMethod,
applyToRail: s.applyToRail,
applyToFirstMile: s.applyToFirstMile,
applyToLastMile: s.applyToLastMile,
};
private matchesTrigger(
condition: TriggerCondition,
state: {
isHazardous: boolean;
hasReefer: boolean;
hasOverweight: boolean;
shippingLineMapped: boolean;
allowConsolidation: boolean;
},
): boolean {
switch (condition) {
case 'CARGO_FLAG_HAZARDOUS':
return state.isHazardous;
case 'CARGO_FLAG_REEFER':
return state.hasReefer;
case 'VGM_EXCEEDS_LIMIT':
return state.hasOverweight;
case 'SHIPPING_LINE_MAPPED':
return state.shippingLineMapped;
case 'CONSOLIDATION_ENABLED':
return state.allowConsolidation;
default:
return false;
}
}
}

View File

@@ -0,0 +1,75 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
import { ApprovalRule } from '../entities/approval-rule.entity';
import {
APPROVAL_RULES_REPOSITORY,
IApprovalRulesRepository,
} from '../interfaces/approval-rules.repository.interface';
@Injectable()
export class ApprovalRulesService {
constructor(
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly repository: IApprovalRulesRepository,
) {}
/** List approval rules. */
async findAll(filter: {
requiresDirectorApproval?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.requiresDirectorApproval !== undefined) {
where.requiresDirectorApproval = filter.requiresDirectorApproval;
}
const [data, total] = await this.repository.findAndCount({
where,
order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get approval chain for a cargo type flag. */
async findChain(requiresDirectorApproval: boolean): Promise<ApprovalRule[]> {
return this.repository.findChainForCargo(requiresDirectorApproval);
}
/** Get an approval rule by ID. */
async findById(id: string): Promise<ApprovalRule> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Approval rule ${id} not found`);
return entity;
}
/** Create an approval rule step. */
async create(dto: CreateApprovalRuleDto): Promise<ApprovalRule> {
return this.repository.create({
requiresDirectorApproval: dto.requiresDirectorApproval,
stepOrder: dto.stepOrder,
requiredRole: dto.requiredRole,
actionLabel: dto.actionLabel,
blocksRole: dto.blocksRole,
});
}
/** Update an approval rule. */
async update(id: string, dto: UpdateApprovalRuleDto): Promise<ApprovalRule> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Approval rule ${id} not found`);
return updated;
}
/** Soft-delete an approval rule. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -1,5 +1,6 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity';
@@ -58,14 +59,15 @@ export class CargoTypesService {
/** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Cargo type with code "${dto.code}" already exists`);
const code = generateCode(dto.cargoTypeName);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Cargo type with name "${dto.cargoTypeName}" conflicts with existing code "${code}"`);
if (dto.parentGroupId) {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
return this.repository.create({
code: dto.code,
code,
cargoTypeName: dto.cargoTypeName,
parentGroupId: dto.parentGroupId ?? null,
showFreeTextBox: dto.showFreeTextBox ?? false,
@@ -78,12 +80,6 @@ export class CargoTypesService {
/** Update an existing cargo type. */
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Cargo type with code "${dto.code}" already exists`);
}
}
if (dto.parentGroupId) {
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
const parent = await this.repository.findById(dto.parentGroupId);

View File

@@ -1,4 +1,5 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerType } from '../entities/container-type.entity';
@@ -27,7 +28,7 @@ export class ContainerTypesService {
const [data, total] = await this.repository.findAndCount({
where,
order: { sizeCode: 'ASC' },
order: { displayOrder: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -43,25 +44,24 @@ export class ContainerTypesService {
/** Create a new container type. */
async create(dto: CreateContainerTypeDto): Promise<ContainerType> {
const existing = await this.repository.findBySizeCode(dto.sizeCode);
if (existing) throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`);
const code = generateCode(dto.label);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${code}"`);
return this.repository.create({
sizeCode: dto.sizeCode,
description: dto.description ?? null,
containersPerWagon: dto.containersPerWagon,
code,
label: dto.label,
sizeFt: dto.sizeFt,
wagonsPerUnit: dto.wagonsPerUnit,
isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/** Update an existing container type. */
async update(id: string, dto: UpdateContainerTypeDto): Promise<ContainerType> {
await this.findById(id);
if (dto.sizeCode) {
const conflict = await this.repository.findBySizeCode(dto.sizeCode);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Container type ${id} not found`);
return updated;

View File

@@ -1,4 +1,5 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
import { PriorityRule } from '../entities/priority-rule.entity';
@@ -27,7 +28,7 @@ export class PriorityRulesService {
const [data, total] = await this.repository.findAndCount({
where,
order: { priorityType: 'ASC' },
order: { label: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -43,16 +44,16 @@ export class PriorityRulesService {
/** Create a new priority rule. */
async create(dto: CreatePriorityRuleDto): Promise<PriorityRule> {
const existing = await this.repository.findAll({ where: { priorityType: dto.priorityType } });
const code = generateCode(dto.label);
const existing = await this.repository.findAll({ where: { code } });
if (existing.length > 0) {
throw new ConflictException(`Priority rule for type "${dto.priorityType}" already exists`);
throw new ConflictException(`Priority rule with label "${dto.label}" conflicts with existing code "${code}"`);
}
return this.repository.create({
priorityType: dto.priorityType,
ruleName: dto.ruleName,
description: dto.description ?? null,
activationCondition: dto.activationCondition ?? null,
bonusPoints: dto.bonusPoints,
code,
label: dto.label,
score: dto.score,
conditionCurrency: dto.conditionCurrency ?? null,
isActive: dto.isActive ?? false,
});
}
@@ -60,7 +61,8 @@ export class PriorityRulesService {
/** Update an existing priority rule. */
async update(id: string, dto: UpdatePriorityRuleDto): Promise<PriorityRule> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Priority rule ${id} not found`);
return updated;
}

View File

@@ -0,0 +1,114 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
@Injectable()
export class RatesService {
constructor(
@Inject(RATES_REPOSITORY)
private readonly repository: IRatesRepository,
) {}
/** List rates with pagination. */
async findAll(filter: {
status?: string;
rateType?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Rate[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
if (filter.rateType) where.rateType = filter.rateType;
const [data, total] = await this.repository.findAndCount({
where,
order: { effectiveFrom: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Return all currently LIVE rates. */
async findLiveRates(): Promise<Rate[]> {
return this.repository.findLiveRates();
}
/** Get a rate by ID. */
async findById(id: string): Promise<Rate> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Rate ${id} not found`);
return entity;
}
/** Create a rate in DRAFT status. */
async create(dto: CreateRateDto): Promise<Rate> {
return this.repository.create({
rateType: dto.rateType as Rate['rateType'],
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
currency: dto.currency,
rateValue: dto.rateValue,
rateUnit: dto.rateUnit as Rate['rateUnit'],
status: 'DRAFT',
proposedByStaffId: dto.proposedByStaffId,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
});
}
/** Update a DRAFT rate. */
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
const existing = await this.findById(id);
if (existing.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT rates can be updated');
}
const updates: Partial<Rate> = {};
if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType'];
if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection;
if (dto.currency) updates.currency = dto.currency;
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
if (dto.proposedByStaffId) updates.proposedByStaffId = dto.proposedByStaffId;
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
const updated = await this.repository.update(id, updates);
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
return updated;
}
/** Submit a DRAFT rate for CEO approval. */
async submitForApproval(id: string): Promise<Rate> {
const rate = await this.findById(id);
if (rate.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT rates can be submitted for approval');
}
const updated = await this.repository.update(id, { status: 'PENDING_APPROVAL' });
return updated!;
}
/** CEO approves a rate — moves to LIVE. */
async approve(id: string, dto: ApproveRateDto): Promise<Rate> {
const rate = await this.findById(id);
if (rate.status !== 'PENDING_APPROVAL') {
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
}
const updated = await this.repository.update(id, {
status: 'LIVE',
approvedByCeoId: dto.approvedByCeoId,
approvedAt: new Date(),
});
return updated!;
}
/** Soft-delete a rate. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -1,5 +1,6 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceType } from '../entities/service-type.entity';
@@ -55,10 +56,11 @@ export class ServiceTypesService {
/** Create a new service type. */
async create(dto: CreateServiceTypeDto): Promise<ServiceType> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Service type with code "${dto.code}" already exists`);
const code = generateCode(dto.serviceName);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
return this.repository.create({
code: dto.code,
code,
serviceName: dto.serviceName,
description: dto.description ?? null,
canBeBookedAlone: dto.canBeBookedAlone ?? true,
@@ -74,13 +76,8 @@ export class ServiceTypesService {
/** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Service type with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated;
}

Some files were not shown because too many files have changed in this diff Show More