complete rule engine and booking flow

This commit is contained in:
marshal
2026-05-30 10:27:59 +03:00
parent 800f036005
commit 430dc44937
74 changed files with 4304 additions and 1248 deletions

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

@@ -102,14 +102,14 @@ export default registerAs(
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [
// IAM schema + tables must be created before freight entity sync
// IAM schema + tables must be created before freight migrations
__dirname +
"/../../node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.js",
__dirname + "/../../migrations/*.{ts,js}",
__dirname + "/../migrations/*.js",
],
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

@@ -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,6 +13,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Add `code` column to existing tables ───────────────────────────
if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) {
await queryRunner.addColumn(
'freight.service_types',
new TableColumn({
@@ -22,19 +23,27 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL`,
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.changeColumn(
'freight.service_types',
'code',
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
await queryRunner.query(
`UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`,
);
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 }),
);
}
if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) {
await queryRunner.addColumn(
'freight.cargo_types',
new TableColumn({
@@ -44,22 +53,29 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL`,
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.changeColumn(
'freight.cargo_types',
'code',
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
await queryRunner.query(
`UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`,
);
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,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

@@ -92,8 +92,8 @@ 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) {

View File

@@ -1,17 +1,33 @@
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 { BookingsController } from './bookings.controller';
import { BookingsRepository } from './bookings.repository';
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],
exports: [BookingsService],

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, In, IsNull, Not, 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);
}
@@ -26,57 +33,112 @@ export class BookingsRepository extends BaseRepository<Booking> {
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.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. */
/** 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 a compatible consolidation partner. */
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,
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
tradeDirection: booking.tradeDirection,
consolidationPartnerId: IsNull(),
status: In(["DRAFT", "PENDING_CONSOLIDATION"]),
status: In(['DRAFT', 'PENDING_CONSOLIDATION']),
id: Not(booking.id),
},
order: { createdAt: "ASC" },
order: { createdAt: 'ASC' },
});
}
@@ -84,23 +146,90 @@ export class BookingsRepository extends BaseRepository<Booking> {
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,24 @@ 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 { 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 +30,68 @@ export class BookingsService {
private readonly minioService: MinioService,
private readonly customersService: CustomersService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
) {}
// ── 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,
};
}
/** Resolve auto-consolidation for odd-quantity 20ft containers. */
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
if (explicit === false) return false;
for (const c of containers) {
const ct = await this.containerTypesService.findById(c.containerTypeId);
if (ct.sizeFt === 20 && c.quantity % 2 !== 0) 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);
}
// ── CRUD ─────────────────────────────────────────────────────────────
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -81,65 +100,83 @@ 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 };
const full = await this.findById(booking.id);
return { booking: full, warnings };
}
/** Update a draft booking. */
@@ -149,46 +186,67 @@ 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);
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],
})),
);
// ── 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);
}
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);
}
const booking = await this.findById(id);
return { booking, warnings };
}
@@ -203,19 +261,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,245 +286,244 @@ 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 this.findById(booking.id);
}
return booking;
}
/** 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");
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
if (!actorId || !requiredRole) {
throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
}
// 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",
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_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";
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: nextStatus,
signedByDirectorId: actorId,
signedByDirectorAt: new Date(),
status: 'APPROVED',
} 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 handleCustomerSign(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['APPROVED']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "SIGNED",
signedByCeoId: actorId,
signedByCeoAt: new Date(),
status: 'SIGNED_CUSTOMER',
customerSignedAt: 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 handleFullyExecuted(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "CANCELLED",
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
} 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 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!;
}
/** ACTIVATE: SIGNED → ACTIVE. */
private async handleActivate(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["SIGNED"]);
private async handleStartTransit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['PAID']);
const updated = await this.bookingsRepository.update(booking.id, {
status: "ACTIVE",
startDate: booking.startDate ?? new Date(),
status: 'IN_TRANSIT',
} as never);
return updated!;
}
/** EXPIRE: ACTIVE → EXPIRED. */
private async handleExpire(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["ACTIVE"]);
private async handleComplete(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['IN_TRANSIT']);
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;
@@ -473,61 +532,63 @@ export class BookingsService {
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 hasOdd20Ft = await this.hasOdd20FtContainer(booking);
if (!hasOdd20Ft) {
throw new BadRequestException(
"Only bookings with odd-quantity 20FT containers need consolidation",
'Only bookings with odd-quantity 20ft containers need consolidation',
);
}
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 partner = await this.bookingsRepository.findConsolidationPartner(booking);
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 };
return {
booking: await this.findById(id),
partner: await this.findById(partner.id),
paired: true,
};
}
// No partner found — enter queue
await this.bookingsRepository.update(booking.id, {
status: "PENDING_CONSOLIDATION",
status: 'PENDING_CONSOLIDATION',
} as never);
const updated = await this.findById(id);
return { booking: updated, partner: null, paired: false };
return { booking: await this.findById(id), partner: null, paired: false };
}
/** Remove consolidation pairing. */
async removeConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking;
}> {
private async hasOdd20FtContainer(booking: Booking): Promise<boolean> {
const containers = booking.bookingContainers ?? [];
for (const bc of containers) {
const ct =
bc.containerType ??
(await this.containerTypesService.findById(bc.containerTypeId));
if (ct.sizeFt === 20 && bc.quantity % 2 !== 0) return true;
}
return false;
}
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;
@@ -540,11 +601,13 @@ export class BookingsService {
}
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),
},
};
return { booking, partner, splitBilling };
}
}

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,183 @@
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 { 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 { 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 })
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null;
@Column({ name: "status", type: "varchar", length: 40, default: "DRAFT" })
@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;
@Column({ name: 'service_type_id', type: 'uuid' })
serviceTypeId!: string;
@Column({ name: "first_mile_enabled", type: "boolean", default: false })
firstMileEnabled!: boolean;
@ManyToOne(() => ServiceType)
@JoinColumn({ name: 'service_type_id' })
serviceType?: ServiceType;
@Column({ name: "first_mile_pickup_address", type: "text", nullable: true })
@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: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@Column({ name: "destination_station", type: "varchar", length: 255 })
destinationStation!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({
name: "cargo_total_weight_vgm",
type: "numeric",
precision: 12,
scale: 3,
})
cargoTotalWeightVgm!: number;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@Column({ name: "freight_type", type: "varchar", length: 20 })
freightType!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
@Column({ name: "freight_subtype", type: "varchar", length: 100, nullable: true })
freightSubtype?: string | null;
@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 ────────────────────────────────────────────────────────────
@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

@@ -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

@@ -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

@@ -1,25 +1,48 @@
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 })
@ApiProperty({ description: 'Unique container code, e.g. 20DV, 40HC', maxLength: 20 })
@IsString()
@MaxLength(20)
sizeCode!: string;
code!: 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,32 @@
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: 'Unique rule code, e.g. USD_PAYER, GOV_REQUEST', maxLength: 40 })
@IsString()
@MaxLength(255)
ruleName!: string;
@MaxLength(40)
code!: string;
@ApiPropertyOptional({ description: 'Explanation of when this rule is triggered' })
@IsOptional()
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
@IsString()
description?: string;
@MaxLength(100)
label!: 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', 'ANY'] 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

@@ -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,32 @@
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 })
@ApiProperty({ description: 'Unique code, e.g. HAZARD, REEFER, OVERWEIGHT', maxLength: 40 })
@IsString()
@MaxLength(50)
@MaxLength(40)
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', 'ANY'] 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 ANY' })
@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,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class CreateYardDto {
@ApiProperty({ description: 'Unique yard code, e.g. KALITY, DJIB_PORT', maxLength: 20 })
@IsString()
@MaxLength(20)
code!: string;
@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 })
.andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', {
.where('rule.container_type_id = :containerTypeId', { containerTypeId })
.andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :any)', {
dir: tradeDirection,
both: 'BOTH',
any: 'ANY',
})
.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,12 @@ import { RuleEngineService } from './rule-engine.service';
ServiceTypesService,
ContainerTypesService,
SurchargeTypesService,
SurchargesService,
WeightLimitRulesService,
PriorityRulesService,
YardsService,
ShippingLinesService,
RatesService,
ApprovalRulesService,
],
})
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) {
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));
}
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) {
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
if (serviceType) {
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;
const priorityRules = await this.priorityRulesRepo.findAllActive();
for (const rule of priorityRules) {
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

@@ -27,7 +27,7 @@ export class ContainerTypesService {
const [data, total] = await this.repository.findAndCount({
where,
order: { sizeCode: 'ASC' },
order: { code: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -43,23 +43,27 @@ 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 existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Container type with code "${dto.code}" already exists`);
return this.repository.create({
sizeCode: dto.sizeCode,
description: dto.description ?? null,
containersPerWagon: dto.containersPerWagon,
code: dto.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 (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`);
throw new ConflictException(`Container type with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);

View File

@@ -27,7 +27,7 @@ export class PriorityRulesService {
const [data, total] = await this.repository.findAndCount({
where,
order: { priorityType: 'ASC' },
order: { code: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -43,16 +43,15 @@ 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 existing = await this.repository.findAll({ where: { code: dto.code } });
if (existing.length > 0) {
throw new ConflictException(`Priority rule for type "${dto.priorityType}" already exists`);
throw new ConflictException(`Priority rule with code "${dto.code}" already exists`);
}
return this.repository.create({
priorityType: dto.priorityType,
ruleName: dto.ruleName,
description: dto.description ?? null,
activationCondition: dto.activationCondition ?? null,
bonusPoints: dto.bonusPoints,
code: dto.code,
label: dto.label,
score: dto.score,
conditionCurrency: dto.conditionCurrency ?? null,
isActive: dto.isActive ?? false,
});
}

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

@@ -0,0 +1,76 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
import { ShippingLine } from '../entities/shipping-line.entity';
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from '../interfaces/shipping-lines.repository.interface';
@Injectable()
export class ShippingLinesService {
constructor(
@Inject(SHIPPING_LINES_REPOSITORY)
private readonly repository: IShippingLinesRepository,
) {}
/** List shipping lines with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: ShippingLine[]; 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.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { code: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a shipping line by ID. */
async findById(id: string): Promise<ShippingLine> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Shipping line ${id} not found`);
return entity;
}
/** Create a shipping line. */
async create(dto: CreateShippingLineDto): Promise<ShippingLine> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Shipping line with code "${dto.code}" already exists`);
return this.repository.create({
code: dto.code,
label: dto.label,
mappedToCode: dto.mappedToCode,
showExtraFeeNotice: dto.showExtraFeeNotice ?? false,
isActive: dto.isActive ?? true,
});
}
/** Update a shipping line. */
async update(id: string, dto: UpdateShippingLineDto): Promise<ShippingLine> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Shipping line with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Shipping line ${id} not found`);
return updated;
}
/** Soft-delete a shipping line. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -27,7 +27,7 @@ export class SurchargeTypesService {
const [data, total] = await this.repository.findAndCount({
where,
order: { name: 'ASC' },
order: { label: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -47,8 +47,9 @@ export class SurchargeTypesService {
if (existing) throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`);
return this.repository.create({
code: dto.code,
name: dto.name,
description: dto.description ?? null,
label: dto.label,
triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'],
rateId: dto.rateId,
isActive: dto.isActive ?? true,
});
}
@@ -62,7 +63,13 @@ export class SurchargeTypesService {
throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
const patch: Partial<SurchargeType> = {};
if (dto.code !== undefined) patch.code = dto.code;
if (dto.label !== undefined) patch.label = dto.label;
if (dto.triggerCondition !== undefined) patch.triggerCondition = dto.triggerCondition as SurchargeType['triggerCondition'];
if (dto.rateId !== undefined) patch.rateId = dto.rateId;
if (dto.isActive !== undefined) patch.isActive = dto.isActive;
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`);
return updated;
}

View File

@@ -1,76 +0,0 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateSurchargeDto } from '../dto/create-surcharge.dto';
import { UpdateSurchargeDto } from '../dto/update-surcharge.dto';
import { Surcharge } from '../entities/surcharge.entity';
import {
ISurchargesRepository,
SURCHARGES_REPOSITORY,
} from '../interfaces/surcharges.repository.interface';
@Injectable()
export class SurchargesService {
constructor(
@Inject(SURCHARGES_REPOSITORY)
private readonly repository: ISurchargesRepository,
) {}
/** List surcharges with pagination. */
async findAll(filter: {
isActive?: boolean;
surchargeTypeId?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Surcharge[]; 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.isActive !== undefined) where.isActive = filter.isActive;
if (filter.surchargeTypeId) where.surchargeTypeId = filter.surchargeTypeId;
const [data, total] = await this.repository.findAndCount({
where,
relations: { surchargeType: true },
order: { feeName: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single surcharge by ID. */
async findById(id: string): Promise<Surcharge> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Surcharge ${id} not found`);
return entity;
}
/** Create a new surcharge. */
async create(dto: CreateSurchargeDto): Promise<Surcharge> {
return this.repository.create({
surchargeTypeId: dto.surchargeTypeId,
feeName: dto.feeName,
triggerDescription: dto.triggerDescription ?? null,
calculationMethod: dto.calculationMethod,
rate: dto.rate,
currency: dto.currency,
applyToRail: dto.applyToRail ?? false,
applyToFirstMile: dto.applyToFirstMile ?? false,
applyToLastMile: dto.applyToLastMile ?? false,
isActive: dto.isActive ?? true,
});
}
/** Update an existing surcharge. */
async update(id: string, dto: UpdateSurchargeDto): Promise<Surcharge> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Surcharge ${id} not found`);
return updated;
}
/** Soft-delete a surcharge. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
@@ -16,20 +16,21 @@ export class WeightLimitRulesService {
/** List weight limit rules with pagination. */
async findAll(filter: {
isActive?: boolean;
containerTypeId?: string;
tradeDirection?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: WeightLimitRule[]; 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.isActive !== undefined) where.isActive = filter.isActive;
if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
const [data, total] = await this.repository.findAndCount({
where,
relations: { containerType: true, surcharge: { surchargeType: true } },
relations: { containerType: true },
order: { effectiveFrom: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -45,27 +46,25 @@ export class WeightLimitRulesService {
/** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
if (dto.warningThresholdTons > dto.maxWeightTons) {
throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons');
}
return this.repository.create({
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
maxWeightTons: dto.maxWeightTons,
warningThresholdTons: dto.warningThresholdTons,
exceededAction: dto.exceededAction,
surchargeId: dto.surchargeId ?? null,
isActive: dto.isActive ?? true,
maxVgmTons: dto.maxVgmTons,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null,
});
}
/** Update an existing weight limit rule. */
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
const existing = await this.findById(id);
const warning = dto.warningThresholdTons ?? existing.warningThresholdTons;
const max = dto.maxWeightTons ?? existing.maxWeightTons;
if (warning > max) throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons');
const updated = await this.repository.update(id, dto);
await this.findById(id);
const patch: Partial<WeightLimitRule> = {};
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo);
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
return updated;
}

View File

@@ -0,0 +1,75 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateYardDto } from '../dto/create-yard.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
import { Yard } from '../entities/yard.entity';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
@Injectable()
export class YardsService {
constructor(
@Inject(YARDS_REPOSITORY)
private readonly repository: IYardsRepository,
) {}
/** List yards with pagination. */
async findAll(filter: {
isActive?: boolean;
country?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Yard[]; 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.isActive !== undefined) where.isActive = filter.isActive;
if (filter.country) where.country = filter.country;
const [data, total] = await this.repository.findAndCount({
where,
order: { displayOrder: 'ASC', code: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a yard by ID. */
async findById(id: string): Promise<Yard> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Yard ${id} not found`);
return entity;
}
/** Create a yard. */
async create(dto: CreateYardDto): Promise<Yard> {
const existing = await this.repository.findByCode(dto.code);
if (existing) throw new ConflictException(`Yard with code "${dto.code}" already exists`);
return this.repository.create({
code: dto.code,
label: dto.label,
country: dto.country,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/** Update a yard. */
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
await this.findById(id);
if (dto.code) {
const conflict = await this.repository.findByCode(dto.code);
if (conflict && conflict.id !== id) {
throw new ConflictException(`Yard with code "${dto.code}" already exists`);
}
}
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
return updated;
}
/** Soft-delete a yard. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}