mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
complete rule engine and booking flow
This commit is contained in:
1002
ITMLS_DB_Design.md
Normal file
1002
ITMLS_DB_Design.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [{ "include": "migrations/**/*", "outDir": "dist" }],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,53 +13,69 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── 1. Add `code` column to existing tables ───────────────────────────
|
||||
|
||||
await queryRunner.addColumn(
|
||||
'freight.service_types',
|
||||
new TableColumn({
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
}),
|
||||
if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.service_types',
|
||||
new TableColumn({
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL`,
|
||||
`UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`,
|
||||
);
|
||||
await queryRunner.changeColumn(
|
||||
'freight.service_types',
|
||||
'code',
|
||||
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.service_types',
|
||||
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
|
||||
const serviceTypesCodeIdx = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`,
|
||||
);
|
||||
if (serviceTypesCodeIdx.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.service_types',
|
||||
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.addColumn(
|
||||
'freight.cargo_types',
|
||||
new TableColumn({
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
}),
|
||||
if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.cargo_types',
|
||||
new TableColumn({
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL`,
|
||||
`UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`,
|
||||
);
|
||||
await queryRunner.changeColumn(
|
||||
'freight.cargo_types',
|
||||
'code',
|
||||
new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }),
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.cargo_types',
|
||||
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
|
||||
const cargoTypesCodeIdx = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`,
|
||||
);
|
||||
if (cargoTypesCodeIdx.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.cargo_types',
|
||||
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. surcharge_types ────────────────────────────────────────────────
|
||||
|
||||
await queryRunner.createTable(
|
||||
if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'surcharge_types',
|
||||
schema: 'freight',
|
||||
@@ -87,7 +103,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
|
||||
|
||||
// ── 3. surcharges ─────────────────────────────────────────────────────
|
||||
|
||||
await queryRunner.createTable(
|
||||
if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'surcharges',
|
||||
schema: 'freight',
|
||||
@@ -136,7 +152,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
|
||||
|
||||
// ── 4. container_types ────────────────────────────────────────────────
|
||||
|
||||
await queryRunner.createTable(
|
||||
if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'container_types',
|
||||
schema: 'freight',
|
||||
@@ -164,7 +180,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
|
||||
|
||||
// ── 5. weight_limit_rules ─────────────────────────────────────────────
|
||||
|
||||
await queryRunner.createTable(
|
||||
if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'weight_limit_rules',
|
||||
schema: 'freight',
|
||||
@@ -229,7 +245,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf
|
||||
|
||||
// ── 6. priority_rules ─────────────────────────────────────────────────
|
||||
|
||||
await queryRunner.createTable(
|
||||
if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'priority_rules',
|
||||
schema: 'freight',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
// ── Rule engine re-evaluation ────────────────────────────────────────
|
||||
const ruleResult = await this.ruleEngineService.evaluate({
|
||||
freightType: dto.freightType ?? existing.freightType,
|
||||
serviceType: dto.serviceType ?? existing.serviceType,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm ?? existing.cargoTotalWeightVgm,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous ?? false,
|
||||
isRefrigerated: dto.isRefrigerated ?? existing.isRefrigerated ?? false,
|
||||
containers,
|
||||
});
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
warnings.push(...ruleResult.warnings);
|
||||
updates.priorityScore = ruleResult.priorityScore;
|
||||
|
||||
if (files.length > 0) {
|
||||
await this.filesService.uploadMany(id, "bookings", files);
|
||||
if (dto.containers) {
|
||||
await this.bookingsRepository.deleteContainers(id);
|
||||
await this.bookingsRepository.createContainers(
|
||||
id,
|
||||
dto.containers.map((c, i) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsRepository.update(id, updates);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
if (files.length > 0) {
|
||||
await this.filesService.uploadMany(id, 'bookings', files);
|
||||
}
|
||||
|
||||
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 booking;
|
||||
return this.findById(booking.id);
|
||||
}
|
||||
|
||||
/** Soft-delete a booking (DRAFT only). */
|
||||
async remove(id: string): Promise<void> {
|
||||
const booking = await this.findById(id);
|
||||
if (booking.status !== "DRAFT") {
|
||||
throw new BadRequestException("Only DRAFT bookings can be deleted");
|
||||
if (booking.status !== 'DRAFT') {
|
||||
throw new BadRequestException('Only DRAFT bookings can be deleted');
|
||||
}
|
||||
await this.bookingsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
// ── status workflow ──────────────────────────────────────────────────
|
||||
|
||||
/** Unified status transition handler. */
|
||||
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
const { action, actorId, reason } = dto;
|
||||
const { action, actorId, reason, requiredRole } = dto;
|
||||
|
||||
switch (action) {
|
||||
case "SUBMIT":
|
||||
case 'SUBMIT':
|
||||
return this.handleSubmit(booking);
|
||||
case "APPROVE_STAFF":
|
||||
return this.handleApproveStaff(booking, actorId);
|
||||
case "APPROVE_DIRECTOR":
|
||||
return this.handleApproveDirector(booking, actorId);
|
||||
case "APPROVE_CEO":
|
||||
return this.handleApproveCeo(booking, actorId);
|
||||
case "REJECT":
|
||||
case 'SEND_QUOTATION':
|
||||
return this.handleSendQuotation(booking);
|
||||
case 'APPROVE_QUOTATION':
|
||||
return this.handleApproveQuotation(booking);
|
||||
case 'REJECT_QUOTATION':
|
||||
return this.handleRejectQuotation(booking, reason);
|
||||
case 'APPROVE_STEP':
|
||||
return this.handleApproveStep(booking, actorId, requiredRole);
|
||||
case 'APPROVE':
|
||||
return this.handleFullyApproved(booking);
|
||||
case 'CUSTOMER_SIGN':
|
||||
return this.handleCustomerSign(booking);
|
||||
case 'MARK_FULLY_EXECUTED':
|
||||
return this.handleFullyExecuted(booking);
|
||||
case 'MARK_PAID':
|
||||
return this.handleMarkPaid(booking);
|
||||
case 'START_TRANSIT':
|
||||
return this.handleStartTransit(booking);
|
||||
case 'COMPLETE':
|
||||
return this.handleComplete(booking);
|
||||
case 'REJECT':
|
||||
return this.handleReject(booking, actorId, reason);
|
||||
case "CANCEL":
|
||||
return this.handleCancel(booking, actorId, reason);
|
||||
case "ACTIVATE":
|
||||
return this.handleActivate(booking);
|
||||
case "EXPIRE":
|
||||
return this.handleExpire(booking);
|
||||
case 'CANCEL':
|
||||
return this.handleCancel(booking, reason);
|
||||
default:
|
||||
throw new BadRequestException(`Unknown action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (cargo routing from rule engine). */
|
||||
/** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
|
||||
private async handleSubmit(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ["DRAFT"]);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(booking);
|
||||
const nextStatus = ruleResult.requiresDirectorApproval
|
||||
? "PENDING_DIRECTOR"
|
||||
: "PENDING_LINE_STAFF";
|
||||
this.assertStatus(booking, ['DRAFT']);
|
||||
|
||||
await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never);
|
||||
await this.ruleEngineService.snapshotLiveRates(booking.id);
|
||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: nextStatus,
|
||||
status: 'PENDING_APPROVAL',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */
|
||||
private async handleApproveStaff(
|
||||
private async handleSendQuotation(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['RFQ_SUBMITTED']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'QUOTATION_SENT',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleApproveQuotation(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['QUOTATION_SENT']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'QUOTATION_APPROVED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleRejectQuotation(booking: Booking, reason?: string): Promise<Booking> {
|
||||
this.assertStatus(booking, ['QUOTATION_SENT']);
|
||||
if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION');
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'QUOTATION_REJECTED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleApproveStep(
|
||||
booking: Booking,
|
||||
actorId?: string,
|
||||
requiredRole?: string,
|
||||
): Promise<Booking> {
|
||||
this.assertStatus(booking, ["PENDING_LINE_STAFF"]);
|
||||
if (!actorId)
|
||||
throw new BadRequestException("actorId is required for APPROVE_STAFF");
|
||||
|
||||
// Line staff cannot approve bookings that require director approval
|
||||
const ruleResult = await this.ruleEngineService.evaluate(booking);
|
||||
if (ruleResult.requiresDirectorApproval) {
|
||||
throw new BadRequestException(
|
||||
"Line staff cannot approve bookings that require director approval",
|
||||
);
|
||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
||||
if (!actorId || !requiredRole) {
|
||||
throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
|
||||
}
|
||||
|
||||
const step = await this.bookingsRepository.findPendingApprovalStep(
|
||||
booking.id,
|
||||
requiredRole,
|
||||
);
|
||||
if (!step) {
|
||||
throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
|
||||
const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id);
|
||||
if (allDone) {
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'APPROVED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
return this.findById(booking.id);
|
||||
}
|
||||
|
||||
private async handleFullyApproved(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: "APPROVED_PENDING_SIGNATURE",
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: new Date(),
|
||||
status: 'APPROVED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** APPROVE_DIRECTOR: APPROVED_PENDING_SIGNATURE|PENDING_DIRECTOR → SIGNED or PENDING_CEO. */
|
||||
private async handleApproveDirector(
|
||||
booking: Booking,
|
||||
actorId?: string,
|
||||
): Promise<Booking> {
|
||||
this.assertStatus(booking, [
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"PENDING_DIRECTOR",
|
||||
]);
|
||||
if (!actorId)
|
||||
throw new BadRequestException("actorId is required for APPROVE_DIRECTOR");
|
||||
|
||||
const ruleResult = await this.ruleEngineService.evaluate(booking);
|
||||
const nextStatus = ruleResult.requiresDirectorApproval ? "PENDING_CEO" : "SIGNED";
|
||||
|
||||
private async handleCustomerSign(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['APPROVED']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: nextStatus,
|
||||
signedByDirectorId: actorId,
|
||||
signedByDirectorAt: new Date(),
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
customerSignedAt: new Date(),
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** APPROVE_CEO: PENDING_CEO → SIGNED. */
|
||||
private async handleApproveCeo(
|
||||
booking: Booking,
|
||||
actorId?: string,
|
||||
): Promise<Booking> {
|
||||
this.assertStatus(booking, ["PENDING_CEO"]);
|
||||
if (!actorId)
|
||||
throw new BadRequestException("actorId is required for APPROVE_CEO");
|
||||
|
||||
private async handleFullyExecuted(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: "SIGNED",
|
||||
signedByCeoId: actorId,
|
||||
signedByCeoAt: new Date(),
|
||||
status: 'FULLY_EXECUTED',
|
||||
fullyExecutedAt: new Date(),
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** REJECT: PENDING_* → CANCELLED. */
|
||||
private async handleReject(
|
||||
booking: Booking,
|
||||
actorId?: string,
|
||||
reason?: string,
|
||||
): Promise<Booking> {
|
||||
this.assertStatus(booking, [
|
||||
"PENDING_LINE_STAFF",
|
||||
"PENDING_DIRECTOR",
|
||||
"PENDING_CEO",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
if (!actorId || !reason)
|
||||
throw new BadRequestException("actorId and reason are required for REJECT");
|
||||
|
||||
private async handleMarkPaid(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: "CANCELLED",
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** CANCEL: DRAFT|PENDING_* → CANCELLED. */
|
||||
private async handleCancel(
|
||||
booking: Booking,
|
||||
_actorId?: string,
|
||||
reason?: string,
|
||||
): Promise<Booking> {
|
||||
this.assertStatus(booking, [
|
||||
"DRAFT",
|
||||
"PENDING_LINE_STAFF",
|
||||
"PENDING_DIRECTOR",
|
||||
"PENDING_CEO",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
if (!reason)
|
||||
throw new BadRequestException("reason is required for CANCEL");
|
||||
|
||||
private async handleStartTransit(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['PAID']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: "CANCELLED",
|
||||
status: 'IN_TRANSIT',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** ACTIVATE: SIGNED → ACTIVE. */
|
||||
private async handleActivate(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ["SIGNED"]);
|
||||
private async handleComplete(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['IN_TRANSIT']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: "ACTIVE",
|
||||
startDate: booking.startDate ?? new Date(),
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** EXPIRE: ACTIVE → EXPIRED. */
|
||||
private async handleExpire(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ["ACTIVE"]);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: "EXPIRED",
|
||||
status: 'COMPLETED',
|
||||
endDate: new Date(),
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** Guard: ensure current status is one of the allowed values. */
|
||||
private async handleReject(
|
||||
booking: Booking,
|
||||
actorId?: string,
|
||||
reason?: string,
|
||||
): Promise<Booking> {
|
||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
||||
if (!actorId || !reason) {
|
||||
throw new BadRequestException('actorId and reason are required for REJECT');
|
||||
}
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'CANCELLED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleCancel(booking: Booking, reason?: string): Promise<Booking> {
|
||||
this.assertStatus(booking, [
|
||||
'DRAFT',
|
||||
'RFQ_SUBMITTED',
|
||||
'QUOTATION_SENT',
|
||||
'QUOTATION_APPROVED',
|
||||
'PENDING_APPROVAL',
|
||||
]);
|
||||
if (!reason) throw new BadRequestException('reason is required for CANCEL');
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'CANCELLED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private assertStatus(booking: Booking, allowed: string[]): void {
|
||||
if (!allowed.includes(booking.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot perform this action on a booking with status "${booking.status}". Allowed: ${allowed.join(", ")}`,
|
||||
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── consolidation ────────────────────────────────────────────────────
|
||||
|
||||
/** Request consolidation — auto-pair if a partner exists, else queue. */
|
||||
async requestConsolidation(id: string): Promise<{
|
||||
booking: Booking;
|
||||
partner: Booking | null;
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateApprovalRuleDto } from './create-approval-rule.dto';
|
||||
|
||||
export class UpdateApprovalRuleDto extends PartialType(CreateApprovalRuleDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateRateDto } from './create-rate.dto';
|
||||
|
||||
export class UpdateRateDto extends PartialType(CreateRateDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateShippingLineDto } from './create-shipping-line.dto';
|
||||
|
||||
export class UpdateShippingLineDto extends PartialType(CreateShippingLineDto) {}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateSurchargeDto } from './create-surcharge.dto';
|
||||
|
||||
export class UpdateSurchargeDto extends PartialType(CreateSurchargeDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateYardDto } from './create-yard.dto';
|
||||
|
||||
export class UpdateYardDto extends PartialType(CreateYardDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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>;
|
||||
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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>;
|
||||
|
||||
@@ -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');
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
import { TriggerCondition } from './entities/surcharge-type.entity';
|
||||
import {
|
||||
ICargoTypesRepository,
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
@@ -9,10 +11,6 @@ import {
|
||||
IServiceTypesRepository,
|
||||
SERVICE_TYPES_REPOSITORY,
|
||||
} from './interfaces/service-types.repository.interface';
|
||||
import {
|
||||
ISurchargesRepository,
|
||||
SURCHARGES_REPOSITORY,
|
||||
} from './interfaces/surcharges.repository.interface';
|
||||
import {
|
||||
IWeightLimitRulesRepository,
|
||||
WEIGHT_LIMIT_RULES_REPOSITORY,
|
||||
@@ -21,20 +19,64 @@ import {
|
||||
IPriorityRulesRepository,
|
||||
PRIORITY_RULES_REPOSITORY,
|
||||
} from './interfaces/priority-rules.repository.interface';
|
||||
import {
|
||||
ISurchargeTypesRepository,
|
||||
SURCHARGE_TYPES_REPOSITORY,
|
||||
} from './interfaces/surcharge-types.repository.interface';
|
||||
import {
|
||||
IRatesRepository,
|
||||
RATES_REPOSITORY,
|
||||
} from './interfaces/rates.repository.interface';
|
||||
import {
|
||||
IApprovalRulesRepository,
|
||||
APPROVAL_RULES_REPOSITORY,
|
||||
} from './interfaces/approval-rules.repository.interface';
|
||||
import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from './interfaces/shipping-lines.repository.interface';
|
||||
|
||||
export interface AppliedSurcharge {
|
||||
feeName: string;
|
||||
rate: number;
|
||||
export interface BookingContainerEvalInput {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
totalVgmTons: number;
|
||||
isReefer?: boolean;
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | null;
|
||||
}
|
||||
|
||||
export interface BookingEvaluationInput {
|
||||
cargoTypeId: string;
|
||||
serviceTypeId: string;
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
isHazardous: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
containers: BookingContainerEvalInput[];
|
||||
}
|
||||
|
||||
export interface AppliedCargoModifier {
|
||||
surchargeTypeId: string;
|
||||
surchargeTypeCode: string;
|
||||
triggerValue: number | null;
|
||||
calculatedAmount: number;
|
||||
rateId: string;
|
||||
currency: string;
|
||||
calculationMethod: Freight.CalculationMethod;
|
||||
applyToRail: boolean;
|
||||
applyToFirstMile: boolean;
|
||||
applyToLastMile: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerWeightResult {
|
||||
containerTypeId: string;
|
||||
weightLimitRuleId: string | null;
|
||||
isOverweight: boolean;
|
||||
overweightExcessTons: number | null;
|
||||
}
|
||||
|
||||
export interface RuleEvaluationResult {
|
||||
priorityScore: number;
|
||||
appliedSurcharges: AppliedSurcharge[];
|
||||
appliedModifiers: AppliedCargoModifier[];
|
||||
containerWeightResults: ContainerWeightResult[];
|
||||
warnings: string[];
|
||||
hardBlocked: string[];
|
||||
requiresDirectorApproval: boolean;
|
||||
@@ -47,150 +89,233 @@ export class RuleEngineService {
|
||||
private readonly cargoTypesRepo: ICargoTypesRepository,
|
||||
@Inject(SERVICE_TYPES_REPOSITORY)
|
||||
private readonly serviceTypesRepo: IServiceTypesRepository,
|
||||
@Inject(SURCHARGES_REPOSITORY)
|
||||
private readonly surchargesRepo: ISurchargesRepository,
|
||||
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
|
||||
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
|
||||
@Inject(PRIORITY_RULES_REPOSITORY)
|
||||
private readonly priorityRulesRepo: IPriorityRulesRepository,
|
||||
@Inject(SURCHARGE_TYPES_REPOSITORY)
|
||||
private readonly surchargeTypesRepo: ISurchargeTypesRepository,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly ratesRepo: IRatesRepository,
|
||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||
private readonly approvalRulesRepo: IApprovalRulesRepository,
|
||||
@Inject(SHIPPING_LINES_REPOSITORY)
|
||||
private readonly shippingLinesRepo: IShippingLinesRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evaluate all rule engine rules against a booking snapshot.
|
||||
* Returns the computed priority score, surcharges to apply, warnings,
|
||||
* hard-block messages, and whether director approval is required.
|
||||
* Callers must throw BadRequestException if hardBlocked is non-empty.
|
||||
*/
|
||||
async evaluate(
|
||||
booking: Pick<
|
||||
Booking,
|
||||
| 'freightType'
|
||||
| 'serviceType'
|
||||
| 'paymentCurrency'
|
||||
| 'cargoTotalWeightVgm'
|
||||
| 'tradeDirection'
|
||||
| 'isHazardous'
|
||||
| 'isRefrigerated'
|
||||
| 'containers'
|
||||
>,
|
||||
): Promise<RuleEvaluationResult> {
|
||||
async evaluate(input: BookingEvaluationInput): Promise<RuleEvaluationResult> {
|
||||
const warnings: string[] = [];
|
||||
const hardBlocked: string[] = [];
|
||||
const appliedSurcharges: AppliedSurcharge[] = [];
|
||||
const appliedModifiers: AppliedCargoModifier[] = [];
|
||||
const containerWeightResults: ContainerWeightResult[] = [];
|
||||
let priorityScore = 0;
|
||||
let requiresDirectorApproval = false;
|
||||
|
||||
// ── 1. Cargo routing ─────────────────────────────────────────────────
|
||||
// Look up CargoType by code to determine director-approval routing.
|
||||
if (booking.freightType) {
|
||||
const cargoType = await this.cargoTypesRepo.findByCode(booking.freightType);
|
||||
if (cargoType?.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
|
||||
} else if (cargoType.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
|
||||
// ── 2. Weight-limit check ────────────────────────────────────────────
|
||||
// For each container group in the booking, find matching active rules
|
||||
// and check whether the per-container VGM exceeds the max weight.
|
||||
const containers = booking.containers ?? [];
|
||||
for (const container of containers) {
|
||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeAndDirection(
|
||||
container.type,
|
||||
booking.tradeDirection,
|
||||
for (const container of input.containers) {
|
||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||
container.containerTypeId,
|
||||
input.tradeDirection,
|
||||
);
|
||||
const rule = rules[0];
|
||||
let isOverweight = container.isOverweight ?? false;
|
||||
let excess = container.overweightExcessTons ?? null;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (container.vgm > rule.maxWeightTons) {
|
||||
const msg =
|
||||
`${container.type} container VGM ${container.vgm}t exceeds max ` +
|
||||
`${rule.maxWeightTons}t (${booking.tradeDirection})`;
|
||||
|
||||
if (rule.exceededAction === Freight.ExceededAction.HARD_BLOCK) {
|
||||
hardBlocked.push(msg);
|
||||
} else {
|
||||
warnings.push(msg);
|
||||
}
|
||||
|
||||
if (rule.surcharge) {
|
||||
appliedSurcharges.push(this.mapSurcharge(rule.surcharge));
|
||||
}
|
||||
} else if (container.vgm > rule.warningThresholdTons) {
|
||||
if (rule) {
|
||||
const maxTotal = Number(rule.maxVgmTons) * container.quantity;
|
||||
const totalVgm = container.totalVgmTons;
|
||||
if (totalVgm > maxTotal) {
|
||||
isOverweight = true;
|
||||
excess = Math.max(0, totalVgm - maxTotal);
|
||||
warnings.push(
|
||||
`${container.type} container VGM ${container.vgm}t is approaching limit ` +
|
||||
`of ${rule.maxWeightTons}t (${booking.tradeDirection})`,
|
||||
`Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`,
|
||||
);
|
||||
}
|
||||
containerWeightResults.push({
|
||||
containerTypeId: container.containerTypeId,
|
||||
weightLimitRuleId: rule.id,
|
||||
isOverweight,
|
||||
overweightExcessTons: excess,
|
||||
});
|
||||
} else {
|
||||
containerWeightResults.push({
|
||||
containerTypeId: container.containerTypeId,
|
||||
weightLimitRuleId: null,
|
||||
isOverweight,
|
||||
overweightExcessTons: excess,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Surcharge flags ───────────────────────────────────────────────
|
||||
if (booking.isHazardous) {
|
||||
const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS');
|
||||
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
|
||||
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
|
||||
if (serviceType) {
|
||||
priorityScore += serviceType.priorityBonusPoints;
|
||||
}
|
||||
|
||||
if (booking.isRefrigerated) {
|
||||
const surcharge = await this.surchargesRepo.findByTypeCode('REFRIGERATED');
|
||||
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
|
||||
}
|
||||
|
||||
// ── 4. Priority scoring ──────────────────────────────────────────────
|
||||
const priorityRules = await this.priorityRulesRepo.findAllActive();
|
||||
|
||||
for (const rule of priorityRules) {
|
||||
switch (rule.priorityType) {
|
||||
case Freight.PriorityType.USD_PAYER:
|
||||
if (booking.paymentCurrency === 'USD') {
|
||||
priorityScore += rule.bonusPoints;
|
||||
}
|
||||
break;
|
||||
|
||||
case Freight.PriorityType.RAIL_AND_FORWARDING: {
|
||||
// Read bonus points from the matching ServiceType DB row
|
||||
const serviceType = await this.serviceTypesRepo.findByCode(booking.serviceType);
|
||||
if (serviceType && serviceType.priorityBonusPoints > 0) {
|
||||
priorityScore += serviceType.priorityBonusPoints;
|
||||
} else if (booking.serviceType === 'RAIL_AND_FORWARDING') {
|
||||
// Fall back to the rule's own bonus_points if no ServiceType found
|
||||
priorityScore += rule.bonusPoints;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Freight.PriorityType.HIGH_VOLUME_SHIPMENT:
|
||||
if (booking.cargoTotalWeightVgm >= 300) {
|
||||
priorityScore += rule.bonusPoints;
|
||||
}
|
||||
break;
|
||||
|
||||
case Freight.PriorityType.GOVERNMENT_ACCOUNT:
|
||||
// TODO: integrate customer accountTier — evaluate when Customer entity is extended
|
||||
break;
|
||||
if (
|
||||
rule.conditionCurrency === null ||
|
||||
rule.conditionCurrency === input.paymentCurrency
|
||||
) {
|
||||
priorityScore += rule.score;
|
||||
}
|
||||
}
|
||||
|
||||
return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval };
|
||||
let shippingLineMapped = false;
|
||||
if (input.shippingLineId) {
|
||||
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
|
||||
shippingLineMapped = Boolean(line?.mappedToCode);
|
||||
}
|
||||
|
||||
const hasReefer = input.containers.some((c) => c.isReefer);
|
||||
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
|
||||
|
||||
const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate();
|
||||
const liveRates = await this.ratesRepo.findLiveRates();
|
||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||||
|
||||
for (const st of surchargeTypes) {
|
||||
const triggered = this.matchesTrigger(st.triggerCondition, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
hasOverweight,
|
||||
shippingLineMapped,
|
||||
allowConsolidation: input.allowConsolidation ?? false,
|
||||
});
|
||||
if (!triggered) continue;
|
||||
|
||||
const rate = st.rate ?? rateById.get(st.rateId);
|
||||
if (!rate) continue;
|
||||
|
||||
let triggerValue: number | null = null;
|
||||
let calculatedAmount = Number(rate.rateValue);
|
||||
|
||||
if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') {
|
||||
triggerValue = containerWeightResults.reduce(
|
||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||
0,
|
||||
);
|
||||
if (rate.rateUnit === 'PER_TON') {
|
||||
calculatedAmount = triggerValue * Number(rate.rateValue);
|
||||
}
|
||||
}
|
||||
|
||||
appliedModifiers.push({
|
||||
surchargeTypeId: st.id,
|
||||
surchargeTypeCode: st.code,
|
||||
triggerValue,
|
||||
calculatedAmount,
|
||||
rateId: rate.id,
|
||||
currency: rate.currency,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
priorityScore,
|
||||
appliedModifiers,
|
||||
containerWeightResults,
|
||||
warnings,
|
||||
hardBlocked,
|
||||
requiresDirectorApproval,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard helper — throws BadRequestException if hardBlocked is non-empty.
|
||||
* Call this immediately after evaluate() in BookingsService.
|
||||
* Instantiate booking_approval_step rows from approval_rules for a cargo type.
|
||||
*/
|
||||
async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise<BookingApprovalStep[]> {
|
||||
const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||
cargoType.requiresDirectorApproval,
|
||||
);
|
||||
|
||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||
const steps: BookingApprovalStep[] = [];
|
||||
|
||||
for (const rule of chain) {
|
||||
const step = stepRepo.create({
|
||||
bookingId,
|
||||
approvalRuleId: rule.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
requiredRole: rule.requiredRole,
|
||||
status: 'PENDING',
|
||||
});
|
||||
steps.push(await stepRepo.save(step));
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot all LIVE rates into booking_rate_snapshot for a booking.
|
||||
*/
|
||||
async snapshotLiveRates(bookingId: string): Promise<BookingRateSnapshot[]> {
|
||||
const liveRates = await this.ratesRepo.findLiveRates();
|
||||
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
|
||||
const now = new Date();
|
||||
const snapshots: BookingRateSnapshot[] = [];
|
||||
|
||||
for (const rate of liveRates) {
|
||||
const snapshot = snapshotRepo.create({
|
||||
bookingId,
|
||||
rateId: rate.id,
|
||||
rateType: rate.rateType,
|
||||
rateValue: rate.rateValue,
|
||||
rateUnit: rate.rateUnit,
|
||||
currency: rate.currency,
|
||||
snapshottedAt: now,
|
||||
});
|
||||
snapshots.push(await snapshotRepo.save(snapshot));
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
/** Guard helper — throws BadRequestException if hardBlocked is non-empty. */
|
||||
assertNoHardBlocks(result: RuleEvaluationResult): void {
|
||||
if (result.hardBlocked.length > 0) {
|
||||
throw new BadRequestException(result.hardBlocked.join('; '));
|
||||
}
|
||||
}
|
||||
|
||||
private mapSurcharge(s: { feeName: string; rate: number; currency: string; calculationMethod: Freight.CalculationMethod; applyToRail: boolean; applyToFirstMile: boolean; applyToLastMile: boolean }): AppliedSurcharge {
|
||||
return {
|
||||
feeName: s.feeName,
|
||||
rate: s.rate,
|
||||
currency: s.currency,
|
||||
calculationMethod: s.calculationMethod,
|
||||
applyToRail: s.applyToRail,
|
||||
applyToFirstMile: s.applyToFirstMile,
|
||||
applyToLastMile: s.applyToLastMile,
|
||||
};
|
||||
private matchesTrigger(
|
||||
condition: TriggerCondition,
|
||||
state: {
|
||||
isHazardous: boolean;
|
||||
hasReefer: boolean;
|
||||
hasOverweight: boolean;
|
||||
shippingLineMapped: boolean;
|
||||
allowConsolidation: boolean;
|
||||
},
|
||||
): boolean {
|
||||
switch (condition) {
|
||||
case 'CARGO_FLAG_HAZARDOUS':
|
||||
return state.isHazardous;
|
||||
case 'CARGO_FLAG_REEFER':
|
||||
return state.hasReefer;
|
||||
case 'VGM_EXCEEDS_LIMIT':
|
||||
return state.hasOverweight;
|
||||
case 'SHIPPING_LINE_MAPPED':
|
||||
return state.shippingLineMapped;
|
||||
case 'CONSOLIDATION_ENABLED':
|
||||
return state.allowConsolidation;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user