This commit is contained in:
Stephanos A
2026-06-23 10:13:36 +03:00
319 changed files with 24404 additions and 4370 deletions

View File

@@ -69,8 +69,8 @@ jobs:
fi
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ node_modules/
coverage/
*.tsbuildinfo
**/*.tsbuildinfo
**/vite.config.ts.timestamp-*.mjs
# env
.env

View File

@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# Portal pages the payment provider redirects the browser to after payment.
# Point these at the freight portal's public payment result routes.
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=

View File

@@ -13,6 +13,7 @@
"lint": "eslint src",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",

View File

@@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { OtpModule } from './modules/otp/otp.module';
import { OtpModule } from "./modules/otp/otp.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
@@ -44,9 +43,10 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -55,7 +55,10 @@ import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({
imports: [
@@ -93,7 +96,6 @@ import { OverviewModule } from './modules/overview/overview.module';
TrainSchedulesModule,
TrainSchedulingModule,
SchedulingRescheduleModule,
CustomersModule,
CompaniesModule,
TrackingModule,
BillingModule,
@@ -112,17 +114,21 @@ import { OverviewModule } from './modules/overview/overview.module';
ContainersModule,
CargoesModule,
RoutesModule,
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,
],
providers: [
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
DemoBookingsSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -131,9 +137,6 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
@@ -144,11 +147,11 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
// FileUploadSettingsSeeder) are intentionally disabled — they stay
// registered as providers but are not run. Re-inject + call .run() to enable.
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
}
}

View File

@@ -117,7 +117,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
],
migrationsRun: true,
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: true,
synchronize: false,
logging: process.env.NODE_ENV === "development",
};
});

View File

@@ -114,7 +114,9 @@ export class ContractViewModelBuilder {
tinNumber: this.valueOrDash(booking.company?.tin),
vatNumber: this.valueOrDash(booking.company?.vatNumber),
fanNumber: this.valueOrDash(booking.company?.fanNumber),
businessLicense: this.valueOrDash(booking.company?.businessLicense),
businessLicense: this.valueOrDash(
booking.company?.companyProfiles?.[0]?.businessLicense,
),
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',

View File

@@ -6,7 +6,7 @@ import { DataSource } from 'typeorm';
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5432),
port: Number(process.env.DB_PORT ?? 5433),
username: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'edr_freight',
@@ -14,7 +14,7 @@ export const AppDataSource = new DataSource({
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false,
logging: true,
logging: process.env.TYPEORM_LOGGING === 'true',
});
// Optional: call ensurePostgresSchemas before initializing

View File

@@ -0,0 +1,138 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateFacilitiesTable1750000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'facilities',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{
name: 'code',
type: 'varchar',
length: '40',
isUnique: true,
},
{
name: 'name',
type: 'varchar',
length: '160',
},
{
name: 'description',
type: 'text',
isNullable: true,
},
{
name: 'facility_type',
type: 'varchar',
length: '32',
},
{
name: 'facility_status',
type: 'varchar',
length: '32',
default: "'ACTIVE'",
},
{
name: 'location_name',
type: 'varchar',
length: '200',
isNullable: true,
},
{
name: 'country',
type: 'varchar',
length: '100',
isNullable: true,
},
{
name: 'city',
type: 'varchar',
length: '100',
isNullable: true,
},
{
name: 'address',
type: 'text',
isNullable: true,
},
{
name: 'latitude',
type: 'numeric',
precision: 10,
scale: 8,
isNullable: true,
},
{
name: 'longitude',
type: 'numeric',
precision: 11,
scale: 8,
isNullable: true,
},
{
name: 'capacity',
type: 'numeric',
precision: 14,
scale: 3,
isNullable: true,
},
{
name: 'is_active',
type: 'boolean',
default: true,
},
{
name: 'notes',
type: 'text',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updated_at',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'deleted_at',
type: 'timestamp',
isNullable: true,
},
],
}),
);
await queryRunner.createIndex(
'freight.facilities',
new TableIndex({
name: 'idx_facilities_code',
columnNames: ['code'],
isUnique: true,
}),
);
await queryRunner.createIndex(
'freight.facilities',
new TableIndex({
name: 'idx_facilities_status',
columnNames: ['facility_status'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.facilities');
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm';
export class AddFacilityIdToWarehouses1750000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.warehouses');
if (!table) {
// warehouses table doesn't exist yet, skip this migration
return;
}
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
if (hasColumn) {
// Column already exists, skip
return;
}
await queryRunner.addColumn(
'freight.warehouses',
new TableColumn({
name: 'facility_id',
type: 'uuid',
isNullable: true,
}),
);
await queryRunner.createForeignKey(
'freight.warehouses',
new TableForeignKey({
columnNames: ['facility_id'],
referencedColumnNames: ['id'],
referencedTableName: 'facilities',
referencedSchema: 'freight',
onDelete: 'SET NULL',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.warehouses');
if (!table) {
return;
}
const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id'));
if (foreignKey) {
await queryRunner.dropForeignKey('freight.warehouses', foreignKey);
}
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
if (hasColumn) {
await queryRunner.dropColumn('freight.warehouses', 'facility_id');
}
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Proof of Delivery (customer pickup) capture on cargoes:
* receiver name, delivered/picked-up timestamp, and delivery remarks.
*/
export class AddProofOfDeliveryToCargoes1750000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.cargoes');
if (!table) {
// cargoes table doesn't exist yet, skip this migration
return;
}
const columnsToAdd = [
{ name: 'receiver_name', type: 'varchar', isNullable: true },
{ name: 'delivered_at', type: 'timestamp', isNullable: true },
{ name: 'delivery_remarks', type: 'text', isNullable: true },
];
const columnsToCreate = columnsToAdd.filter(
(col) => !table.columns.some((c) => c.name === col.name),
);
if (columnsToCreate.length > 0) {
await queryRunner.addColumns(
'freight.cargoes',
columnsToCreate.map((col) => new TableColumn(col)),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('freight.cargoes');
if (!table) {
return;
}
const columnNames = ['receiver_name', 'delivered_at', 'delivery_remarks'];
const columnsToRemove = columnNames.filter((name) =>
table.columns.some((c) => c.name === name),
);
if (columnsToRemove.length > 0) {
await queryRunner.dropColumns('freight.cargoes', columnsToRemove);
}
}
}

View File

@@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
/**
* Batch 4.5 — warehouse inspection reports + inventory inspection status.
*/
export class AddWarehouseInspection1750000000003 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// inventory.inspection_status
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
if (!hasColumn) {
await queryRunner.addColumn(
'freight.warehouse_inventory',
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
);
}
}
// warehouse_inspection_reports table
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
if (!inspectionTable) {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_inspection_reports',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'inventory_id', type: 'uuid' },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'customer_id', type: 'uuid', isNullable: true },
{ name: 'report_type', type: 'varchar', length: '32', default: "'INSPECTION'" },
{ name: 'inspection_status', type: 'varchar', length: '20', default: "'NEEDS_REVIEW'" },
{ name: 'has_damage', type: 'boolean', default: false },
{ name: 'damage_description', type: 'text', isNullable: true },
{ name: 'has_weight_loss', type: 'boolean', default: false },
{ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'weight_loss_unit', type: 'varchar', length: '12', isNullable: true },
{ name: 'has_missing_items', type: 'boolean', default: false },
{ name: 'missing_items_description', type: 'text', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'inspected_by_id', type: 'uuid', isNullable: true },
{ name: 'inspected_at', type: 'timestamptz', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
indices: [
{ name: 'idx_wir_inventory', columnNames: ['inventory_id'] },
{ name: 'idx_wir_booking', columnNames: ['booking_id'] },
{ name: 'idx_wir_status', columnNames: ['inspection_status'] },
],
}),
true,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
if (inspectionTable) {
await queryRunner.dropTable('freight.warehouse_inspection_reports', true);
}
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
if (hasColumn) {
await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status');
}
}
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPhysicalWagonToTrainSetWagons1750200000000 implements MigrationInterface {
name = 'AddPhysicalWagonToTrainSetWagons1750200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_schema = 'freight'
AND table_name = 'train_set_wagons'
AND constraint_name = 'fk_train_set_wagons_physical_wagon'
) THEN
ALTER TABLE freight.train_set_wagons
ADD CONSTRAINT fk_train_set_wagons_physical_wagon
FOREIGN KEY (physical_wagon_id)
REFERENCES freight.wagons(id)
ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon
ON freight.train_set_wagons(physical_wagon_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_set_wagons_physical_wagon;`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
DROP CONSTRAINT IF EXISTS fk_train_set_wagons_physical_wagon;
`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
DROP COLUMN IF EXISTS physical_wagon_id;
`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCurrentLocationToWagons1750300000000 implements MigrationInterface {
name = 'AddCurrentLocationToWagons1750300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS current_location_yard_id UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE constraint_schema = 'freight'
AND table_name = 'wagons'
AND constraint_name = 'FK_wagons_current_location_yard_id'
) THEN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagons_current_location_yard_id"
FOREIGN KEY (current_location_yard_id)
REFERENCES freight.yards(id)
ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_wagons_current_location_yard_id"
ON freight.wagons(current_location_yard_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagons_current_location_yard_id";`);
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP CONSTRAINT IF EXISTS "FK_wagons_current_location_yard_id";
`);
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS current_location_yard_id;
`);
}
}

View File

@@ -0,0 +1,214 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type FleetRow = {
code: string;
name: string;
count: number;
start: number;
end: number;
capacityTons: number;
tareWeight: number;
lengthMeters: number;
supportedLoadTypes: string[];
};
const FLEET: FleetRow[] = [
{
code: 'PW2',
name: 'Box wagon',
count: 220,
start: 1,
end: 220,
capacityTons: 70,
tareWeight: 25.2,
lengthMeters: 17.066,
supportedLoadTypes: ['BULK', 'GENERAL_CARGO', 'BAGGED_CARGO', 'BOXED_CARGO'],
},
{
code: 'CW4',
name: 'Gondola wagon covered',
count: 110,
start: 221,
end: 330,
capacityTons: 70,
tareWeight: 24.8,
lengthMeters: 13.976,
supportedLoadTypes: ['CONTAINER'],
},
{
code: 'CW3',
name: 'Gondola wagon',
count: 20,
start: 331,
end: 350,
capacityTons: 70,
tareWeight: 23.4,
lengthMeters: 13.976,
supportedLoadTypes: ['BULK', 'COAL', 'ORE'],
},
{
code: 'KW2',
name: 'Hopper wagon covered',
count: 20,
start: 351,
end: 370,
capacityTons: 69,
tareWeight: 25.2,
lengthMeters: 16.466,
supportedLoadTypes: ['BULK', 'GRAIN'],
},
{
code: 'KW3',
name: 'Hopper wagon',
count: 20,
start: 371,
end: 390,
capacityTons: 70,
tareWeight: 24,
lengthMeters: 14.4,
supportedLoadTypes: ['BULK', 'COAL'],
},
{
code: 'NW5',
name: 'Flat wagon container',
count: 550,
start: 391,
end: 940,
capacityTons: 70,
tareWeight: 0,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
},
];
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
export class SeedEdRWagonFleet1750400000000 implements MigrationInterface {
name = 'SeedEdRWagonFleet1750400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.wagon_types
SET name = 'Flat wagon container',
capacity_tons = 70,
length_meters = 14.000,
supported_load_types = ARRAY['CONTAINER'],
max_wagons_per_train = 53,
is_active = true,
deleted_at = NULL,
updated_at = now()
WHERE code = 'NW5';
`);
const [defaultLocation] = await queryRunner.query(`
SELECT id
FROM freight.yards
WHERE code IN ('DJIBOUTI', 'DJIB_PORT', 'NAGAD')
OR lower(label) LIKE '%djibouti%'
ORDER BY
CASE code
WHEN 'DJIBOUTI' THEN 1
WHEN 'DJIB_PORT' THEN 2
WHEN 'NAGAD' THEN 3
ELSE 4
END,
display_order ASC
LIMIT 1;
`);
const defaultLocationYardId = defaultLocation?.id ?? null;
for (const row of FLEET) {
await queryRunner.query(
`
INSERT INTO freight.wagon_types (
code,
name,
capacity_tons,
length_meters,
max_wagons_per_train,
supported_load_types,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6::text[], true)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
supported_load_types = EXCLUDED.supported_load_types,
is_active = true,
deleted_at = NULL,
updated_at = now();
`,
[
row.code,
row.name,
row.capacityTons,
row.lengthMeters,
row.supportedLoadTypes.includes('CONTAINER') ? 53 : 37,
row.supportedLoadTypes,
],
);
const [typeRecord] = await queryRunner.query(
`SELECT id FROM freight.wagon_types WHERE code = $1 LIMIT 1;`,
[row.code],
);
if (!typeRecord?.id) {
throw new Error(`wagon_type_seed_failed:${row.code}`);
}
if (row.end - row.start + 1 !== row.count) {
throw new Error(`wagon_range_mismatch:${row.code}`);
}
for (let sequence = row.start; sequence <= row.end; sequence += 1) {
await queryRunner.query(
`
INSERT INTO freight.wagons (
wagon_number,
wagon_type_id,
tare_weight,
max_payload_weight,
current_location_yard_id,
status,
notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (wagon_number) DO UPDATE SET
wagon_type_id = EXCLUDED.wagon_type_id,
tare_weight = EXCLUDED.tare_weight,
max_payload_weight = EXCLUDED.max_payload_weight,
current_location_yard_id = CASE
WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.current_location_yard_id
ELSE freight.wagons.current_location_yard_id
END,
status = CASE
WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.status
ELSE freight.wagons.status
END,
notes = EXCLUDED.notes,
updated_at = now();
`,
[
wagonNumber(sequence),
typeRecord.id,
row.tareWeight,
row.capacityTons,
defaultLocationYardId,
defaultLocationYardId ? 'IMPORT_READY' : 'AVAILABLE',
`Seeded Ethio-Djibouti Railway ${row.code} fleet record.`,
],
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.wagons
WHERE wagon_number BETWEEN 'ER0001' AND 'ER0940';
`);
}
}

View File

@@ -0,0 +1,103 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
} from "typeorm";
export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
name = "CreateCompanyProfiles1752000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: "freight",
name: "company_profiles",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
default: "gen_random_uuid()",
},
{ name: "company_id", type: "uuid" },
{ name: "type", type: "varchar", length: "32" },
{ name: "reference", type: "varchar", length: "20", isUnique: true },
{
name: "status",
type: "varchar",
length: "32",
default: "'active'",
},
{
name: "business_license",
type: "varchar",
length: "100",
isNullable: true,
},
{ name: "attributes", type: "jsonb", 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.createForeignKey(
"freight.company_profiles",
new TableForeignKey({
columnNames: ["company_id"],
referencedTableName: "companies",
referencedSchema: "freight",
referencedColumnNames: ["id"],
}),
);
await queryRunner.createIndex(
"freight.company_profiles",
new TableIndex({ columnNames: ["company_id"] }),
);
await queryRunner.createIndex(
"freight.company_profiles",
new TableIndex({ columnNames: ["type"] }),
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("freight.company_profiles");
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MoveBusinessLicenseToProfile1752000000001
implements MigrationInterface
{
name = 'MoveBusinessLicenseToProfile1752000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.company_profiles cp
SET business_license = c.business_license
FROM freight.companies c
WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
`);
await queryRunner.query(
`ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
);
await queryRunner.query(`
UPDATE freight.companies c
SET business_license = cp.business_license
FROM (
SELECT DISTINCT ON (cp2.company_id)
cp2.company_id, cp2.business_license
FROM freight.company_profiles cp2
WHERE cp2.business_license IS NOT NULL
ORDER BY cp2.company_id, cp2.created_at
) cp
WHERE cp.company_id = c.id
`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
CREATE TABLE freight.vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plate_number VARCHAR NOT NULL UNIQUE,
registration_number VARCHAR NOT NULL UNIQUE,
vehicle_type VARCHAR NOT NULL,
manufacturer VARCHAR NOT NULL,
model VARCHAR NOT NULL,
year INTEGER NOT NULL,
fuel_type VARCHAR NOT NULL,
capacity NUMERIC NOT NULL,
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL
);
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
}
}

View File

@@ -0,0 +1,156 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Full wagon re-seed — runs in this order:
*
* 1. DELETE all existing wagons (hard delete, not soft).
* 2. UPSERT all 10 standard wagon types so they are guaranteed to exist.
* 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across
* the 5 main operational yards (10 wagons per yard per type):
*
* KALITY — Kality Rail Terminal
* MOJO — Mojo Dry Port
* DIRE_DAWA — Dire Dawa Yard
* DJIB_PORT — Djibouti Port Terminal
* NAGAD — Nagad Terminal, Djibouti
*
* Wagon numbers follow the pattern <TYPE_CODE>-NNNN (e.g. NW5-0001 … NW5-0050).
* Yard IDs are fetched live from freight.yards so the migration is safe across
* all environments regardless of UUID values.
*/
export class SeedWagonsWithYardAssignment1784000000001
implements MigrationInterface
{
name = 'SeedWagonsWithYardAssignment1784000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── STEP 1: Remove all wagons ──────────────────────────────────────────
await queryRunner.query(`DELETE FROM freight.wagons;`);
// ── STEP 2: Ensure all 10 wagon types exist ────────────────────────────
await queryRunner.query(`
INSERT INTO freight.wagon_types (
code,
name,
capacity_tons,
length_meters,
max_wagons_per_train,
supported_load_types,
is_active,
tare_weight_tons
)
VALUES
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0),
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0),
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0),
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0),
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0),
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0),
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0),
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0),
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0),
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
supported_load_types = EXCLUDED.supported_load_types,
is_active = true,
tare_weight_tons = EXCLUDED.tare_weight_tons,
deleted_at = NULL,
updated_at = now();
`);
// ── STEP 3: Seed 50 wagons per type across 5 yards ────────────────────
await queryRunner.query(`
DO $$
DECLARE
wt RECORD;
yard_kality UUID;
yard_mojo UUID;
yard_dire_dawa UUID;
yard_djib_port UUID;
yard_nagad UUID;
yards UUID[];
i INT;
yard_id UUID;
wagon_num TEXT;
v_tare NUMERIC;
v_payload NUMERIC;
BEGIN
-- Fetch yard IDs by code (safe across envs — UUIDs differ per DB)
SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1;
SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1;
SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1;
SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1;
SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1;
IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL
OR yard_djib_port IS NULL OR yard_nagad IS NULL
THEN
RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.';
END IF;
yards := ARRAY[
yard_kality,
yard_mojo,
yard_dire_dawa,
yard_djib_port,
yard_nagad
];
FOR wt IN
SELECT id, code, capacity_tons, tare_weight_tons
FROM freight.wagon_types
WHERE is_active = true
ORDER BY code
LOOP
v_tare := COALESCE(wt.tare_weight_tons, 20.0);
v_payload := COALESCE(wt.capacity_tons, 60.0);
FOR i IN 1 .. 50 LOOP
wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0');
yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K …
INSERT INTO freight.wagons (
id,
wagon_number,
wagon_type_id,
tare_weight,
max_payload_weight,
status,
current_yard_id,
train_id,
sequence_number,
notes,
train_set_wagon_id,
current_train_schedule_id,
created_at,
updated_at
)
VALUES (
uuid_generate_v4(),
wagon_num,
wt.id,
v_tare,
v_payload,
'Available',
yard_id,
NULL, NULL, NULL, NULL, NULL,
now(), now()
)
ON CONFLICT (wagon_number) DO NOTHING;
END LOOP;
RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code;
END LOOP;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Remove all seeded wagons (full wipe — mirrors what up() did)
await queryRunner.query(`DELETE FROM freight.wagons;`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Day-level booking pool: customers select a DAY (route + day), not a specific
* train. The batch engine's pool query filters bookings on
* (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
* index backs that scan.
*/
export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_route_day
ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
}
}

View File

@@ -0,0 +1,125 @@
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
/**
* Batch 5 — warehouse allocation rules, storage/demurrage fee rules,
* and demurrage lifecycle timestamps on inventory.
*/
export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_allocation_rules',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'name', type: 'varchar', length: '160' },
{ name: 'priority', type: 'int', default: 100 },
{ name: 'freight_type', type: 'varchar', length: '16', isNullable: true },
{ name: 'trade_direction', type: 'varchar', length: '16', isNullable: true },
{ name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true },
{ name: 'container_status', type: 'varchar', length: '24', isNullable: true },
{ name: 'requires_inspection', type: 'boolean', isNullable: true },
{ name: 'target_facility_code', type: 'varchar', length: '40', isNullable: true },
{ name: 'target_yard_code', type: 'varchar', length: '40' },
{ name: 'target_warehouse_code', type: 'varchar', length: '40', isNullable: true },
{ name: 'target_zone_code', type: 'varchar', length: '40', isNullable: true },
{ name: 'storage_type', type: 'varchar', length: '80', isNullable: true },
{ 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 },
],
indices: [
{ name: 'idx_war_priority', columnNames: ['priority'] },
{ name: 'idx_war_active', columnNames: ['is_active'] },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_fee_rules',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'name', type: 'varchar', length: '160' },
{ name: 'rule_type', type: 'varchar', length: '20' },
{ name: 'priority', type: 'int', default: 100 },
{ name: 'freight_type', type: 'varchar', length: '16', isNullable: true },
{ name: 'trade_direction', type: 'varchar', length: '16', isNullable: true },
{ name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true },
{ name: 'container_type', type: 'varchar', length: '40', isNullable: true },
{ name: 'facility_id', type: 'uuid', isNullable: true },
{ name: 'warehouse_id', type: 'uuid', isNullable: true },
{ name: 'yard_id', type: 'uuid', isNullable: true },
{ name: 'zone_id', type: 'uuid', isNullable: true },
{ name: 'free_days', type: 'int', default: 0 },
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ 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 },
],
indices: [
{ name: 'idx_wfr_type', columnNames: ['rule_type'] },
{ name: 'idx_wfr_active', columnNames: ['is_active'] },
],
}),
true,
);
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const columnsToAdd = [
{ name: 'inspection_started_at', type: 'timestamptz', isNullable: true },
{ name: 'inspection_completed_at', type: 'timestamptz', isNullable: true },
{ name: 'ready_for_pickup_at', type: 'timestamptz', isNullable: true },
{ name: 'release_date', type: 'timestamptz', isNullable: true },
{ name: 'gate_cleared_at', type: 'timestamptz', isNullable: true },
];
const columnsToCreate = columnsToAdd.filter(
(col) => !inventoryTable.columns.some((c) => c.name === col.name),
);
if (columnsToCreate.length > 0) {
await queryRunner.addColumns(
'freight.warehouse_inventory',
columnsToCreate.map((col) => new TableColumn(col)),
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
if (inventoryTable) {
const columnNames = [
'inspection_started_at',
'inspection_completed_at',
'ready_for_pickup_at',
'release_date',
'gate_cleared_at',
];
const columnsToRemove = columnNames.filter((name) =>
inventoryTable.columns.some((c) => c.name === name),
);
if (columnsToRemove.length > 0) {
await queryRunner.dropColumns('freight.warehouse_inventory', columnsToRemove);
}
}
const feeRulesTable = await queryRunner.getTable('freight.warehouse_fee_rules');
if (feeRulesTable) {
await queryRunner.dropTable('freight.warehouse_fee_rules', true);
}
const allocationRulesTable = await queryRunner.getTable('freight.warehouse_allocation_rules');
if (allocationRulesTable) {
await queryRunner.dropTable('freight.warehouse_allocation_rules', true);
}
}
}

View File

@@ -0,0 +1,124 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateWarehouseModule1790000000000 implements MigrationInterface {
name = 'CreateWarehouseModule1790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(160) NOT NULL,
code VARCHAR(40) NOT NULL UNIQUE,
type VARCHAR(32) NOT NULL,
station_id UUID NULL,
location_name VARCHAR(200) NULL,
capacity_weight NUMERIC(14,3) NULL,
capacity_containers INT NULL,
current_weight NUMERIC(14,3) NOT NULL DEFAULT 0,
current_containers INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_yards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id) ON DELETE CASCADE,
name VARCHAR(160) NOT NULL,
code VARCHAR(40) NOT NULL,
type VARCHAR(32) NOT NULL,
capacity_weight NUMERIC(14,3) NULL,
capacity_containers INT NULL,
current_weight NUMERIC(14,3) NOT NULL DEFAULT 0,
current_containers INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_warehouse_yards_code UNIQUE (warehouse_id, code)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_zones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id) ON DELETE CASCADE,
name VARCHAR(160) NOT NULL,
code VARCHAR(40) NOT NULL,
type VARCHAR(32) NOT NULL,
capacity_weight NUMERIC(14,3) NULL,
capacity_containers INT NULL,
current_weight NUMERIC(14,3) NOT NULL DEFAULT 0,
current_containers INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_warehouse_zones_code UNIQUE (yard_id, code)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_inventory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id),
yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id),
zone_id UUID NOT NULL REFERENCES freight.warehouse_zones(id),
booking_id UUID NOT NULL,
cargo_id UUID NULL,
container_id UUID NULL,
goods_id UUID NULL,
quantity NUMERIC(12,3) NOT NULL DEFAULT 0,
weight NUMERIC(14,3) NOT NULL DEFAULT 0,
volume NUMERIC(12,3) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE',
arrived_at TIMESTAMPTZ NULL,
inspected_at TIMESTAMPTZ NULL,
ready_for_loading_at TIMESTAMPTZ NULL,
notes TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
const indexes: Array<[string, string, string]> = [
['idx_warehouses_type', 'warehouses', 'type'],
['idx_warehouses_status', 'warehouses', 'status'],
['idx_warehouses_station_id', 'warehouses', 'station_id'],
['idx_warehouse_yards_warehouse_id', 'warehouse_yards', 'warehouse_id'],
['idx_warehouse_yards_type', 'warehouse_yards', 'type'],
['idx_warehouse_yards_status', 'warehouse_yards', 'status'],
['idx_warehouse_zones_yard_id', 'warehouse_zones', 'yard_id'],
['idx_warehouse_zones_type', 'warehouse_zones', 'type'],
['idx_warehouse_zones_status', 'warehouse_zones', 'status'],
['idx_warehouse_inventory_warehouse_id', 'warehouse_inventory', 'warehouse_id'],
['idx_warehouse_inventory_yard_id', 'warehouse_inventory', 'yard_id'],
['idx_warehouse_inventory_zone_id', 'warehouse_inventory', 'zone_id'],
['idx_warehouse_inventory_booking_id', 'warehouse_inventory', 'booking_id'],
['idx_warehouse_inventory_cargo_id', 'warehouse_inventory', 'cargo_id'],
['idx_warehouse_inventory_container_id', 'warehouse_inventory', 'container_id'],
['idx_warehouse_inventory_goods_id', 'warehouse_inventory', 'goods_id'],
['idx_warehouse_inventory_status', 'warehouse_inventory', 'status'],
];
for (const [indexName, table, column] of indexes) {
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS ${indexName} ON freight.${table}(${column});`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zones;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_yards;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouses;`);
}
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
/** Batch 6 — warehouse fee invoices + invoice items. */
export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_fee_invoices',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'invoice_number', type: 'varchar', length: '40', isUnique: true },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'customer_id', type: 'uuid', isNullable: true },
{ name: 'inventory_id', type: 'uuid' },
{ name: 'facility_id', type: 'uuid', isNullable: true },
{ name: 'warehouse_id', type: 'uuid', isNullable: true },
{ name: 'yard_id', type: 'uuid', isNullable: true },
{ name: 'zone_id', type: 'uuid', isNullable: true },
{ name: 'invoice_type', type: 'varchar', length: '32', default: "'MIXED_WAREHOUSE_FEES'" },
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
{ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'period_start', type: 'timestamptz', isNullable: true },
{ name: 'period_end', type: 'timestamptz', isNullable: true },
{ name: 'issued_at', type: 'timestamptz', isNullable: true },
{ name: 'due_date', type: 'timestamptz', isNullable: true },
{ name: 'paid_at', type: 'timestamptz', isNullable: true },
{ name: 'cancelled_at', type: 'timestamptz', isNullable: true },
{ name: 'payments', type: 'jsonb', default: "'[]'" },
{ name: 'notes', 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 },
],
indices: [
{ name: 'idx_wfi_booking', columnNames: ['booking_id'] },
{ name: 'idx_wfi_inventory', columnNames: ['inventory_id'] },
{ name: 'idx_wfi_status', columnNames: ['status'] },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_fee_invoice_items',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'invoice_id', type: 'uuid' },
{ name: 'fee_rule_id', type: 'uuid', isNullable: true },
{ name: 'fee_type', type: 'varchar', length: '32' },
{ name: 'description', type: 'varchar', length: '255' },
{ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 },
{ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'chargeable_days', type: 'int', isNullable: true },
{ name: 'free_days', type: 'int', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['invoice_id'],
referencedSchema: 'freight',
referencedTableName: 'warehouse_fee_invoices',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
indices: [{ name: 'idx_wfii_invoice', columnNames: ['invoice_id'] }],
}),
true,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.warehouse_fee_invoice_items', true);
await queryRunner.dropTable('freight.warehouse_fee_invoices', true);
}
}

View File

@@ -0,0 +1,123 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class WarehouseBatch21790000000001 implements MigrationInterface {
name = 'WarehouseBatch21790000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Capacity columns (weight + volume) on warehouse / yard / zone ──────
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
await queryRunner.query(`
ALTER TABLE freight.${table}
ADD COLUMN IF NOT EXISTS max_weight NUMERIC(14,3) NULL,
ADD COLUMN IF NOT EXISTS max_volume NUMERIC(14,3) NULL,
ADD COLUMN IF NOT EXISTS current_volume NUMERIC(14,3) NOT NULL DEFAULT 0;
`);
// Backfill max_weight from the Batch 1 capacity_weight column.
await queryRunner.query(`
UPDATE freight.${table} SET max_weight = capacity_weight WHERE max_weight IS NULL;
`);
}
// ── Inventory lifecycle: migrate Batch 1 statuses to Batch 2 set ───────
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ALTER COLUMN status SET DEFAULT 'RECEIVED';
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory SET status = 'RECEIVED' WHERE status = 'ARRIVED_AT_WAREHOUSE';
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory SET status = 'STORED' WHERE status = 'UNDER_INSPECTION';
`);
// ── New lifecycle timestamps ──────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS stored_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ NULL,
ADD COLUMN IF NOT EXISTS dispatched_at TIMESTAMPTZ NULL;
`);
// booking_id becomes nullable (inventory can exist before booking linkage).
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory ALTER COLUMN booking_id DROP NOT NULL;
`);
// ── Movement history ──────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_inventory_movement (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
from_warehouse_id UUID NOT NULL,
from_yard_id UUID NOT NULL,
from_zone_id UUID NOT NULL,
to_warehouse_id UUID NOT NULL,
to_yard_id UUID NOT NULL,
to_zone_id UUID NOT NULL,
remarks TEXT NULL,
moved_by VARCHAR(120) NULL,
moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_movement_inventory_id
ON freight.warehouse_inventory_movement(inventory_id);
`);
// ── Activity log ──────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id UUID NULL,
warehouse_id UUID NULL,
activity_type VARCHAR(40) NOT NULL,
description TEXT NULL,
performed_by VARCHAR(120) NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_inventory_id
ON freight.warehouse_activity_log(inventory_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_warehouse_id
ON freight.warehouse_activity_log(warehouse_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_activity_type
ON freight.warehouse_activity_log(activity_type);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_activity_log;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory_movement;`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS stored_at,
DROP COLUMN IF EXISTS reserved_at,
DROP COLUMN IF EXISTS loaded_at,
DROP COLUMN IF EXISTS dispatched_at;
`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory ALTER COLUMN status SET DEFAULT 'RECEIVED';
`);
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
await queryRunner.query(`
ALTER TABLE freight.${table}
DROP COLUMN IF EXISTS max_weight,
DROP COLUMN IF EXISTS max_volume,
DROP COLUMN IF EXISTS current_volume;
`);
}
}
}

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Batch 3 — Warehouse → Loading → Train Departure visibility.
* Adds the warehouse_loadings record (inventory ↔ wagon). Does NOT touch any
* scheduling / wagon tables — the warehouse only reads from those.
*/
export class WarehouseBatch31790000000002 implements MigrationInterface {
name = 'WarehouseBatch31790000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_loadings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
warehouse_inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
booking_id UUID NULL,
wagon_id UUID NOT NULL,
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
loaded_by VARCHAR(120) NULL,
loaded_weight NUMERIC(14,3) NULL,
notes TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_inventory_id
ON freight.warehouse_loadings(warehouse_inventory_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_booking_id
ON freight.warehouse_loadings(booking_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_wagon_id
ON freight.warehouse_loadings(wagon_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_loadings;`);
}
}

View File

@@ -1,6 +1,5 @@
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
@@ -69,7 +68,12 @@ export class BookingTransitionService {
priorityScore,
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
// arrives. The returned status reflects that outcome.
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -143,7 +147,10 @@ export class BookingTransitionService {
},
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
// Same consolidation treatment as the direct submit path.
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -188,18 +195,11 @@ export class BookingTransitionService {
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
// Consolidation gate: a booking whose containers don't fill whole wagons
// cannot be accepted until it is paired with a complementary booking.
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
if (gate.blocked) {
throw new ConflictException(
gate.message ??
'Booking requires consolidation and cannot be accepted until a partner is found.',
);
}
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,

View File

@@ -11,6 +11,7 @@ import {
Query,
Request,
Res,
UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
@@ -117,8 +118,22 @@ export class BookingsController {
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
) {
// Staff (backoffice) see every booking. Customers (portal) are always
// force-scoped to their own company, regardless of any companyId they pass.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) return { items: [], total: 0 };
return this.bookingsService.findAll(filter, companyId);
}
@Get('list-summary')
@@ -166,18 +181,60 @@ export class BookingsController {
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
async findByReference(@Param('reference') reference: string) {
async findByReference(
@Param('reference') reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/tracking')
@ApiOperation({
summary: 'Shipment tracking timeline for a booking',
description:
"Returns the booking's consignment (once dispatched) and its ordered " +
'tracking events. Scoped to the customer\'s own company.',
})
async findTracking(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.getBookingTracking(id);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })

View File

@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
// Only pair bookings the customer has committed (SUBMITTED) or that are
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
// pairing never prematurely submits an unfinished/unpriced draft.
.andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
@@ -686,6 +689,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
destinationStationId?: string;
schedulingStatus?: string;
trainScheduleId?: string;
/**
* EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
* the whole (route, day) pool rather than bookings pre-targeted to one train.
*/
day?: string;
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
@@ -703,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
// Mirror the automatic batch pool: a schedule only ever considers bookings that
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
if (options.trainScheduleId) {
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
// booking that still carries train_schedule_id).
if (options.day) {
qb.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day: options.day },
);
} else if (options.trainScheduleId) {
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
trainScheduleId: options.trainScheduleId,
});
@@ -762,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/**
* Day-level batch pool: ready, not-yet-allocated bookings on a route for one
* EAT calendar day, regardless of which train they end up on. Same status
* rules and ordering as {@link findBatchPool}, but keyed on
* (origin, destination, day) instead of train_schedule_id — the engine then
* distributes these across all trains departing that day.
*/
findBatchPoolByRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id = :originYardId', { originYardId })
.andWhere('booking.destination_yard_id = :destinationYardId', {
destinationYardId,
})
.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -1,12 +1,17 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { SchedulingStatus } from '@edr/types';
import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -52,6 +57,8 @@ export class BookingsService {
private readonly minioService: MinioService,
// private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
@@ -146,13 +153,17 @@ export class BookingsService {
/**
* Enable consolidation when any container line leaves a wagon partially filled
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
*
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
* half-empty wagon, so `explicit === false` is ignored when consolidation is
* actually needed. The opt-in flag only matters for cargo that already fills
* whole wagons (where consolidation is moot anyway).
*/
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
if (explicit === false) return false;
const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
@@ -193,10 +204,11 @@ export class BookingsService {
return { booking: paired, messages };
}
if (booking.status === 'DRAFT') {
await this.bookingsRepository.update(booking.id, {
status: 'PENDING_CONSOLIDATION',
} as never);
// No partner yet — park the booking so it waits. Applies both pre-submit
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
// reach this method.
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
await this.bookingsRepository.parkForConsolidation(booking.id);
}
const pending = await this.findById(booking.id);
@@ -205,45 +217,24 @@ export class BookingsService {
}
/**
* Consolidation gate used at staff-accept time. Returns the (possibly newly
* paired) booking plus whether it still needs a consolidation partner.
* When a booking needs consolidation and none is found, it is parked in
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
* waits for a later complementary booking to complete the wagon.
*
* Returns the re-fetched booking, so callers can reflect the resulting status
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
*/
async resolveConsolidationGate(bookingId: string): Promise<{
booking: Booking;
blocked: boolean;
message?: string;
}> {
let booking = await this.findById(bookingId);
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
const booking = await this.findById(bookingId);
// Already paired — passes the gate.
// Already paired (e.g. a partner submitted first) — nothing to do.
if (booking.consolidationPartnerId) {
return { booking, blocked: false };
return booking;
}
const needs =
await this.consolidationService.needsConsolidationFromBooking(booking);
if (!needs) {
return { booking, blocked: false };
}
// A partner may have appeared since submission — try to pair now.
const result = await this.tryAutoConsolidate(booking);
booking = result.booking;
if (booking.consolidationPartnerId) {
return { booking, blocked: false, message: result.messages.join(' ') };
}
// Still no partner — park it and block the accept.
await this.bookingsRepository.parkForConsolidation(booking.id);
booking = await this.findById(booking.id);
const slots = await this.consolidationService.slotsFromBooking(booking);
return {
booking,
blocked: true,
message: this.consolidationService.describePending(booking, slots),
};
return result.booking;
}
/** Create a new freight booking. */
@@ -283,8 +274,8 @@ export class BookingsService {
companyId = company.id;
}
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: dto.trainScheduleId } });
@@ -300,6 +291,22 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
dto.destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
}
const reference = dto.reference || (await this.generateReference());
@@ -575,6 +582,7 @@ export class BookingsService {
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -587,7 +595,9 @@ export class BookingsService {
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
companyId: filter.companyId,
// A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings.
companyId: forceCompanyId ?? filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -631,6 +641,109 @@ export class BookingsService {
});
}
/**
* Resolve the company a customer user belongs to, for scoping their own
* bookings. Returns null when no profile/company is linked yet.
*/
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
try {
const { company } =
await this.companiesService.getCompanyInfoByUserId(userId);
return company?.id ?? null;
} catch {
return null;
}
}
/**
* Authorize a customer's access to a single booking. Staff are scoped at the
* controller (they pass `isStaff`); for a customer, the booking must belong
* to the company the authenticated user is linked to — otherwise it is hidden
* behind a NotFound so booking IDs can't be probed.
*/
async assertCustomerCanAccessBooking(
userId: string | undefined,
booking: Booking,
): Promise<void> {
if (!userId) {
throw new ForbiddenException('Authentication required');
}
const companyId = await this.resolveCustomerCompanyId(userId);
if (!companyId || booking.companyId !== companyId) {
// Don't reveal that the booking exists for another company.
throw new NotFoundException(`Booking ${booking.id} not found`);
}
}
/**
* Build the customer-facing shipment tracking payload for a booking from the
* train schedule it is assigned to and the live checkpoint log. The caller is
* responsible for authorizing access to the booking first.
*
* When the booking has not been assigned to a train yet, returns a valid
* "no schedule" payload so the UI can show a pre-dispatch state.
*/
async getBookingTracking(
bookingId: string,
): Promise<Freight.IBookingTracking> {
const booking = await this.findById(bookingId);
const empty: Freight.IBookingTracking = {
bookingId: booking.id,
bookingReference: booking.reference,
hasSchedule: false,
scheduleId: null,
trainNumber: null,
scheduleStatus: null,
direction: null,
origin: null,
destination: null,
stations: [],
checkpoints: [],
currentSequenceNo: -1,
actualDepartureAt: null,
actualArrivalAt: null,
scheduledDepartureAt: null,
scheduledArrivalAt: null,
};
if (!booking.trainScheduleId) {
return empty;
}
// Pull the live corridor + checkpoints for the assigned schedule. If the
// schedule was removed, fall back to the pre-dispatch state rather than 500.
let track: Awaited<
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
>;
try {
track = await this.trainSchedulingService.getScheduleCheckpoints(
booking.trainScheduleId,
);
} catch {
return empty;
}
return {
bookingId: booking.id,
bookingReference: booking.reference,
hasSchedule: true,
scheduleId: track.scheduleId,
trainNumber: track.trainNumber,
scheduleStatus: track.status as Freight.TrainScheduleStatus,
direction: track.direction,
origin: track.origin,
destination: track.destination,
stations: track.stations,
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
currentSequenceNo: track.currentSequenceNo,
actualDepartureAt: track.actualDepartureAt,
actualArrivalAt: track.actualArrivalAt,
scheduledDepartureAt: track.scheduledDepartureAt,
scheduledArrivalAt: track.scheduledArrivalAt,
};
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;

View File

@@ -91,12 +91,21 @@ export class CreateBookingDto {
@IsUUID()
trainId?: string;
/** Target schedule this booking is created against (required by the backoffice create form). */
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
/**
* Staff-only manual pin to a specific train. Customers omit this — they pick a
* DAY via {@link scheduledDate} and the batch engine assigns a train within
* that (route, day) pool. When provided, the schedule must be OPEN and on the
* booking route.
*/
@ApiPropertyOptional({
format: 'uuid',
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
/** The day the customer wants to ship (the pool day key). */
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;

View File

@@ -23,6 +23,9 @@ export const BOOKING_STATUSES = [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
'APPROVED',
'READY_FOR_ASSIGNMENT',
'WAGON_ASSIGNED',
'INVOICED',
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
@@ -277,7 +280,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null;
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
/**
* The train this booking is assigned to. FK to train_schedules.
*
* Day-level pooling: customers no longer pick a train — they pick a DAY, and
* this stays null at creation. The batch engine sets it when it assigns the
* booking to a specific train within its (route, day) pool; staff may also
* pin it manually. The day-level pool is keyed on
* (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
*/
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;

View File

@@ -157,7 +157,9 @@ export class CargoesService {
}
cargo.status = 'DELIVERED';
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
cargo.deliveredAt = dto?.pickupDate ? new Date(dto.pickupDate) : new Date();
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
const remaining =
cargo.containerId != null

View File

@@ -1,6 +1,16 @@
import { IsOptional, IsString } from 'class-validator';
import { IsDateString, IsOptional, IsString } from 'class-validator';
export class DeliverCargoDto {
/** Name of the person who received / picked up the cargo (Proof of Delivery). */
@IsOptional()
@IsString()
receiverName?: string;
/** When the cargo was picked up / delivered. Defaults to now. */
@IsOptional()
@IsDateString()
pickupDate?: string;
@IsOptional()
@IsString()
deliveryRemarks?: string;

View File

@@ -40,6 +40,16 @@ export class Cargo extends BaseEntity {
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
unloadedAt!: Date | null;
// Proof of Delivery (customer pickup) capture.
@Column({ name: 'receiver_name', type: 'varchar', nullable: true })
receiverName!: string | null;
@Column({ name: 'delivered_at', type: 'timestamp', nullable: true })
deliveredAt!: Date | null;
@Column({ name: 'delivery_remarks', type: 'text', nullable: true })
deliveryRemarks!: string | null;
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
wagonBookingAllocationId!: string | null;
@@ -57,6 +67,7 @@ export class Cargo extends BaseEntity {
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
loadType!: string | null;
// Relationship to Container
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
@JoinColumn({ name: 'container_id' })
container!: Container | null;

View File

@@ -1,22 +1,38 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FreightAdmin } from '../../common/booking-guards';
import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { ResponseCompanyDto } from './dto/response-company.dto';
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseInterceptors,
UploadedFiles,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import { FreightAdmin } from "../../common/booking-guards";
import { FilesService } from "../files/files.service";
import { CompaniesService } from "./companies.service";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
interface CurrentIamUser {
id: string;
@@ -25,36 +41,47 @@ interface CurrentIamUser {
phoneNumber?: string;
}
@ApiTags('Companies')
@Controller('companies')
@ApiTags("Companies")
@Controller("companies")
export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
) {}
) { }
@Get('getInfo')
@ApiOperation({ summary: 'Get company info for the current user' })
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
@Get("getInfo")
@ApiOperation({ summary: "Get company info for the current user" })
async getInfo(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
return new CompanyInfoResponseDto(profile, company);
}
@Get('profile')
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
@Get("profile")
@ApiOperation({ summary: "Get flattened profile for the settings page" })
async getProfile(
@CurrentUser() user: CurrentIamUser,
): Promise<ProfileResponseDto> {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
return new ProfileResponseDto(profile, company);
}
@Get('dashboard')
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
@Get("dashboard")
@ApiOperation({
summary:
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
})
async getDashboard(
@CurrentUser() user: CurrentIamUser,
): Promise<DashboardSummaryResponseDto> {
return this.companiesService.getDashboardSummary(user.id);
}
@Patch('profile')
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
@Patch("profile")
@ApiOperation({ summary: "Update profile (flattened settings page)" })
async updateProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: UpdateProfileDto,
@@ -62,145 +89,153 @@ export class CompaniesController {
return this.companiesService.updateProfile(user.id, dto);
}
@Post('create')
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
@Post("company-profiles")
@ApiOperation({
summary:
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
})
async addCompanyProfiles(
@CurrentUser() user: CurrentIamUser,
@Body() dto: AddCompanyProfilesDto,
): Promise<ResponseCompanyProfileDto[]> {
const profiles = await this.companiesService.addCompanyProfilesForUser(
user.id,
dto.types,
);
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
// Used by portal
@Post("create")
@ApiOperation({
summary:
"Create a company with its associated external profile (onboarding)",
})
async createWithProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CreateCompanyWithProfileDto,
): Promise<CompanyInfoResponseDto> {
const nameParts = (user.name?.en ?? '').split(' ');
const { profile, company } = await this.companiesService.createCompanyWithProfile(
{
userId: user.id,
firstName: nameParts[0] || '',
lastName: nameParts.slice(-1)[0] || '',
email: user.email ?? '',
phone: user.phoneNumber ?? '',
},
dto,
);
const nameParts = (user.name?.en ?? "").split(" ");
const { profile, company } =
await this.companiesService.createCompanyWithProfile(
{
userId: user.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user.email ?? "",
phone: user.phoneNumber ?? "",
},
dto,
);
return new CompanyInfoResponseDto(profile, company);
}
// Used by backoffice
@Post()
@FreightAdmin()
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
@ApiOperation({
summary:
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
})
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
const company = await this.companiesService.createCompany(dto);
return new ResponseCompanyDto(company);
}
@Get()
@ApiOperation({ summary: 'List all companies' })
@ApiOperation({ summary: "List all companies" })
async findAll(): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.map((c) => new ResponseCompanyDto(c));
}
@Get('type/:type')
@ApiOperation({ summary: 'Find companies by type' })
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
@Get("type/:type")
@ApiOperation({ summary: "Find companies by type" })
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
return companies
.filter((c) => c.type === type)
.map((c) => new ResponseCompanyDto(c));
}
@Get('search')
@ApiOperation({ summary: 'Search companies by name' })
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
@Get("search")
@ApiOperation({ summary: "Search companies by name" })
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
.map((c) => new ResponseCompanyDto(c));
}
@Get(':id')
@ApiOperation({ summary: 'Get company by ID' })
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
@Get(":id")
@ApiOperation({ summary: "Get company by ID" })
async findById(
@Param("id", ParseUUIDPipe) id: string,
): Promise<ResponseCompanyDto> {
const company = await this.companiesService.findCompanyById(id);
return new ResponseCompanyDto(company);
}
@Patch(':id')
@Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: 'Update a company' })
@ApiOperation({ summary: "Update a company" })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto,
): Promise<ResponseCompanyDto> {
const company = await this.companiesService.updateCompany(id, dto);
return new ResponseCompanyDto(company);
}
@Delete(':id')
@Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: 'Soft-delete a company' })
@ApiOperation({ summary: "Soft-delete a company" })
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteCompany(id);
}
@Post(':companyId/documents')
@Post(":companyId/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
async uploadDocuments(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
) {
return this.filesService.uploadMany(companyId, 'companies', files);
return this.filesService.uploadMany(companyId, "companies", files);
}
@Post(':companyId/profiles')
@Post(":companyId/profiles")
@FreightAdmin()
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
@ApiOperation({ summary: "Add a profile (employee) to a company" })
async createProfile(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: CreateExternalProfileDto,
): Promise<ResponseExternalProfileDto> {
const profile = await this.companiesService.createProfile({ ...dto, companyId });
const profile = await this.companiesService.createProfile({
...dto,
companyId,
});
return new ResponseExternalProfileDto(profile);
}
@Get(':companyId/profiles')
@ApiOperation({ summary: 'List profiles for a company' })
@Get(":companyId/profiles")
@ApiOperation({ summary: "List profiles for a company" })
async listProfiles(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<ResponseExternalProfileDto[]> {
const profiles = await this.companiesService.findProfilesByCompany(companyId);
const profiles =
await this.companiesService.findProfilesByCompany(companyId);
return profiles.map((p) => new ResponseExternalProfileDto(p));
}
@Get('profile/user/:userId')
@ApiOperation({ summary: 'Get profile by IAM user ID' })
@Get("profile/user/:userId")
@ApiOperation({ summary: "Get profile by IAM user ID" })
async findProfileByUser(
@Param('userId', ParseUUIDPipe) userId: string,
@Param("userId", ParseUUIDPipe) userId: string,
): Promise<ResponseExternalProfileDto> {
const profile = await this.companiesService.findProfileByUserId(userId);
return new ResponseExternalProfileDto(profile);
}
@Post('ff-clients')
@FreightAdmin()
@ApiOperation({ summary: 'Link a forwarder to a client company' })
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
const client = await this.companiesService.createFFClient(dto);
return new ResponseFFClientDto(client);
}
@Get(':forwarderCompanyId/clients')
@ApiOperation({ summary: 'List clients of a forwarder' })
async listFFClients(
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
): Promise<ResponseFFClientDto[]> {
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
return clients.map((c) => new ResponseFFClientDto(c));
}
@Delete('ff-clients/:id')
@FreightAdmin()
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
@HttpCode(HttpStatus.NO_CONTENT)
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteFFClient(id);
}
}

View File

@@ -1,21 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FilesModule } from "../files/files.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
imports: [
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
FilesModule,
],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
providers: [
CompaniesService,
CompaniesRepository,
ExternalProfileRepository,
CompanyProfileRepository,
CompanyDashboardRepository,
],
exports: [CompaniesService],
})
export class CompaniesModule {}
export class CompaniesModule { }

View File

@@ -1,19 +1,27 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
CompanyProfile,
ProfileType,
ProfileStatus,
} from "./entities/company-profile.entity";
export interface UserIdentity {
userId: string;
@@ -27,10 +35,10 @@ export interface UserIdentity {
export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly ffClientsRepo: FFClientRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
) {}
) { }
async createCompany(dto: CreateCompanyDto): Promise<Company> {
const exists = await this.companiesRepo.existsByTin(dto.tin);
@@ -40,27 +48,33 @@ export class CompaniesService {
return this.companiesRepo.create(dto);
}
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
async createCompanyWithProfile(
identity: UserIdentity,
dto: CreateCompanyWithProfileDto,
): Promise<{ company: Company; profile: ExternalProfile }> {
if (dto.tin) {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
throw new ConflictException(
`Company with TIN ${dto.tin} already exists`,
);
}
}
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
if (existingProfile) {
throw new ConflictException(`Profile with email ${identity.email} already exists`);
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
);
}
const company = await this.companiesRepo.create({
name: dto.companyName,
type: dto.companyType,
tin: dto.tin ?? '',
tin: dto.tin ?? "",
vatNumber: dto.vatNumber ?? null,
businessLicense: dto.fanNumber ?? null,
fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? 'Ethiopia',
country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null,
phone: dto.companyPhone ?? null,
email: dto.companyEmail ?? null,
@@ -78,11 +92,39 @@ export class CompaniesService {
isPrimaryContact: dto.isPrimaryContact ?? true,
});
// Persist the operational role(s) chosen during onboarding. Types are
// already constrained to the company type on the client; any that don't
// match are skipped defensively rather than failing the whole signup.
if (dto.companyProfiles?.length) {
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const input of dto.companyProfiles) {
if (!allowedTypes.includes(input.type)) continue;
const existing = await this.companyProfilesRepo.findByType(
company.id,
input.type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(
input.type,
);
await this.companyProfilesRepo.create({
companyId: company.id,
type: input.type,
reference,
businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Active,
});
}
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
company.id,
);
}
return { company, profile };
}
async findAllCompanies(): Promise<Company[]> {
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
return this.companiesRepo.findAll({ order: { name: "ASC" } });
}
async findCompanyById(id: string): Promise<Company> {
@@ -91,12 +133,21 @@ export class CompaniesService {
return company;
}
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
async getCompanyInfoByUserId(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const company = profile.company;
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
if (!company)
throw new NotFoundException(
`Company for profile ${profile.id} not found`,
);
company.companyProfiles =
await this.companyProfilesRepo.findByCompanyId(company.id);
return { profile, company };
}
@@ -114,7 +165,9 @@ export class CompaniesService {
* column, so "delivered YTD" counts bookings created this year that reached a
* delivered/completed status.
*/
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
async getDashboardSummary(
userId: string,
): Promise<DashboardSummaryResponseDto> {
// A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders.
const profile = await this.profilesRepo.findByUserId(userId);
@@ -125,7 +178,9 @@ export class CompaniesService {
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
// Same point in the previous year, so YoY compares like-for-like windows.
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
const prevYearToDate = new Date(
prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
);
const [
deliveredThis,
@@ -139,20 +194,36 @@ export class CompaniesService {
this.dashboardRepo.countDelivered(companyId, yearStart, now),
this.dashboardRepo.countCommitted(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.sumPaidSpendByCurrency(
companyId,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
this.dashboardRepo.sumCommittedTonnage(
companyId,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.monthlyCommittedTonnage(
companyId,
this.monthsAgo(now, 5),
now,
),
]);
// Spend can span currencies; report the dominant one (prefer ETB on ties).
const spend = this.pickCurrencyTotal(spendThisByCcy);
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
const spendPrev =
spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
return {
deliveredCount: deliveredThis,
// Share of committed bookings that reached delivered/completed.
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
completionRate:
committedThis > 0
? Math.round((deliveredThis / committedThis) * 100)
: 0,
spendYtd: spend.total,
spendCurrency: spend.currency,
spendYtdChangePct: this.changePct(spend.total, spendPrev),
@@ -172,12 +243,12 @@ export class CompaniesService {
deliveredCount: 0,
completionRate: 0,
spendYtd: 0,
spendCurrency: 'ETB',
spendCurrency: "ETB",
spendYtdChangePct: 0,
freightVolume: {
totalTonnes: 0,
totalValue: 0,
currency: 'ETB',
currency: "ETB",
ytdChangePct: 0,
monthly: this.buildMonthlySeries(now, []),
},
@@ -190,8 +261,11 @@ export class CompaniesService {
}
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
if (totals.length === 0) return { currency: 'ETB', total: 0 };
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
currency: string;
total: number;
} {
if (totals.length === 0) return { currency: "ETB", total: 0 };
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
}
@@ -206,13 +280,29 @@ export class CompaniesService {
now: Date,
rows: { year: number; month: number; tonnes: number }[],
): { month: string; tonnes: number }[] {
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const labels = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
const series: { month: string; tonnes: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
series.push({
month: labels[d.getMonth()],
tonnes: Math.round(byKey.get(key) ?? 0),
});
}
return series;
}
@@ -224,7 +314,10 @@ export class CompaniesService {
return updated;
}
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
async updateProfile(
userId: string,
dto: UpdateProfileDto,
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const companyUpdates: Record<string, any> = {};
@@ -233,30 +326,38 @@ export class CompaniesService {
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
companyUpdates.businessLicense = dto.fanNumber;
companyUpdates.fanNumber = dto.fanNumber;
}
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined)
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
companyUpdates.attributes = attrUpdates;
const updated = await this.companiesRepo.update(company.id, companyUpdates);
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
}
@@ -270,7 +371,9 @@ export class CompaniesService {
const existing = await this.profilesRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictException(`Profile with email ${dto.email} already exists`);
throw new ConflictException(
`Profile with email ${dto.email} already exists`,
);
}
return this.profilesRepo.create(dto);
@@ -278,7 +381,8 @@ export class CompaniesService {
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
return profile;
}
@@ -286,28 +390,119 @@ export class CompaniesService {
return this.profilesRepo.findByCompanyId(companyId);
}
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
await this.findCompanyById(dto.forwarderCompanyId);
await this.findCompanyById(dto.clientCompanyId);
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
switch (companyType) {
case "customer":
return [ProfileType.importer, ProfileType.exporter];
case "freight_forwarder":
return [ProfileType.freightForwarder];
case "dj_freight_forwarder":
return [ProfileType.djFreightForwarder];
case "transporter":
return [ProfileType.transporter];
default:
return [];
}
}
const existing = await this.ffClientsRepo.findRelationship(
dto.forwarderCompanyId,
dto.clientCompanyId,
);
if (existing) {
throw new ConflictException('This forwarder-client relationship already exists');
async createCompanyProfile(
companyId: string,
profileType?: ProfileType,
): Promise<CompanyProfile> {
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
const type = profileType ?? allowedTypes[0];
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
return this.ffClientsRepo.create(dto);
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (existing) {
throw new ConflictException(
`Company already has a ${type} profile (${existing.reference})`,
);
}
const reference = await this.companyProfilesRepo.generateReference(type);
return this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
});
}
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
async createDefaultProfilesForCompany(
companyId: string,
): Promise<CompanyProfile[]> {
const company = await this.findCompanyById(companyId);
const types = this.getProfileTypeForCompanyType(company.type);
const profiles: CompanyProfile[] = [];
for (const type of types) {
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (!existing) {
profiles.push(await this.createCompanyProfile(companyId, type));
}
}
if (profiles.length === 0) {
throw new BadRequestException(
`Company of type "${company.type}" must have at least one operational profile`,
);
}
return profiles;
}
async deleteFFClient(id: string): Promise<void> {
const client = await this.ffClientsRepo.findById(id);
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
await this.ffClientsRepo.softDelete(id);
/**
* Add operational profile(s) to the current user's company (portal settings).
* Add-only and idempotent: each requested type must be allowed for the
* company's type, profiles that already exist are skipped (not re-created or
* rejected), and the full updated list is returned.
*/
async addCompanyProfilesForUser(
userId: string,
types: ProfileType[],
): Promise<CompanyProfile[]> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const type of types) {
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
});
}
return this.companyProfilesRepo.findByCompanyId(companyId);
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
const SEQUENCE_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "seq_company_profile_ex",
[ProfileType.importer]: "seq_company_profile_im",
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
[ProfileType.transporter]: "seq_company_profile_tr",
};
const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "EX",
[ProfileType.importer]: "IM",
[ProfileType.freightForwarder]: "FFE",
[ProfileType.djFreightForwarder]: "FWJ",
[ProfileType.transporter]: "TR",
};
@Injectable()
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
constructor(
@InjectRepository(CompanyProfile)
repo: Repository<CompanyProfile>,
) {
super(repo);
}
async generateReference(type: ProfileType): Promise<string> {
const seqName = SEQUENCE_MAP[type];
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);
const nextId = result[0].next_id as number;
const prefix = PREFIX_MAP[type];
return `${prefix}-${String(nextId).padStart(5, "0")}`;
}
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {
return this.repository.find({
where: { companyId },
relations: ["company"],
});
}
async findByType(
companyId: string,
type: ProfileType,
): Promise<CompanyProfile | null> {
return this.repository.findOne({
where: { companyId, type },
});
}
async findByReference(reference: string): Promise<CompanyProfile | null> {
return this.repository.findOne({ where: { reference } });
}
}

View File

@@ -0,0 +1,9 @@
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
import { ProfileType } from "../entities/company-profile.entity";
export class AddCompanyProfilesDto {
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
types!: ProfileType[];
}

View File

@@ -1,5 +1,17 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
import { Type } from 'class-transformer';
import { CompanyType } from '../entities/company.entity';
import { ProfileType } from '../entities/company-profile.entity';
export class CompanyProfileInputDto {
@IsEnum(ProfileType)
type!: ProfileType;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
}
export class CreateCompanyWithProfileDto {
@IsEnum(CompanyType)
@@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto {
@IsOptional()
attributes?: Record<string, any>;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CompanyProfileInputDto)
companyProfiles?: CompanyProfileInputDto[];
}

View File

@@ -25,11 +25,6 @@ export class CreateCompanyDto {
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
@IsOptional()
@IsString()
@MaxLength(32)

View File

@@ -1,24 +0,0 @@
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
import { FFClientRelationship } from '../entities/ff-client.entity';
export class CreateFFClientDto {
@IsUUID()
@IsNotEmpty()
forwarderCompanyId!: string;
@IsUUID()
@IsNotEmpty()
clientCompanyId!: string;
@IsOptional()
@IsEnum(FFClientRelationship)
relationshipType?: FFClientRelationship;
@IsOptional()
@IsBoolean()
canBookOnBehalf?: boolean;
@IsOptional()
@IsBoolean()
canViewDocuments?: boolean;
}

View File

@@ -1,9 +1,11 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import { ResponseCompanyProfileDto } from './response-company.dto';
export class ProfileResponseDto {
companyId: string;
companyName: string;
companyType: string;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
vatNumber: string | null;
fanNumber: string | null;
companyProfiles: ResponseCompanyProfileDto[];
contactPersonName: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
constructor(profile: ExternalProfile, company: Company) {
this.companyId = company.id;
this.companyName = company.name;
this.companyType = company.type;
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country;

View File

@@ -1,6 +1,29 @@
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
import { CompanyProfile } from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class ResponseCompanyProfileDto {
id: string;
type: string;
reference: string;
status: string;
businessLicense?: string | null;
attributes?: Record<string, any> | null;
createdAt: Date;
updatedAt: Date;
constructor(profile: CompanyProfile) {
this.id = profile.id;
this.type = profile.type;
this.reference = profile.reference;
this.status = profile.status;
this.businessLicense = profile.businessLicense;
this.attributes = profile.attributes;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
}
}
export class ResponseCompanyDto {
id: string;
name: string;
@@ -8,7 +31,6 @@ export class ResponseCompanyDto {
status: CompanyStatus;
tin: string;
vatNumber?: string | null;
businessLicense?: string | null;
fanNumber?: string | null;
country: string;
address?: string | null;
@@ -17,6 +39,7 @@ export class ResponseCompanyDto {
website?: string | null;
attributes?: Record<string, any> | null;
profiles?: ResponseExternalProfileDto[];
companyProfiles?: ResponseCompanyProfileDto[];
createdAt: Date;
updatedAt: Date;
@@ -27,7 +50,6 @@ export class ResponseCompanyDto {
this.status = company.status;
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.businessLicense = company.businessLicense;
this.fanNumber = company.fanNumber;
this.country = company.country;
this.address = company.address;
@@ -36,6 +58,7 @@ export class ResponseCompanyDto {
this.website = company.website;
this.attributes = company.attributes;
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -1,23 +0,0 @@
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
export class ResponseFFClientDto {
id: string;
forwarderCompanyId: string;
clientCompanyId: string;
relationshipType: FFClientRelationship;
canBookOnBehalf: boolean;
canViewDocuments: boolean;
createdAt: Date;
updatedAt: Date;
constructor(client: FFClient) {
this.id = client.id;
this.forwarderCompanyId = client.forwarderCompanyId;
this.clientCompanyId = client.clientCompanyId;
this.relationshipType = client.relationshipType;
this.canBookOnBehalf = client.canBookOnBehalf;
this.canViewDocuments = client.canViewDocuments;
this.createdAt = client.createdAt;
this.updatedAt = client.updatedAt;
}
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateFFClientDto } from './create-ff-client.dto';
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}

View File

@@ -0,0 +1,62 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
export enum ProfileType {
importer = "importer",
exporter = "exporter",
freightForwarder = "freight_forwarder",
djFreightForwarder = "dj_freight_forwarder",
transporter = "transporter",
}
export enum ProfileStatus {
Active = "active",
Pending = "pending",
Suspended = "suspended",
Blacklisted = "blacklisted",
}
@Entity({ schema: "freight", name: "company_profiles" })
@Index(["reference"], { unique: true })
@Index(["type"])
@Index(["companyId"])
export class CompanyProfile extends BaseEntity {
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
@ManyToOne(() => Company, (company) => company.companyProfiles)
@JoinColumn({ name: "company_id" })
company!: Company;
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
type!: ProfileType;
@Column({
name: "reference",
type: "varchar",
length: 20,
nullable: false,
unique: true,
})
reference!: string;
@Column({
name: "status",
type: "varchar",
length: 32,
default: ProfileStatus.Active,
})
status!: ProfileStatus;
@Column({
name: "business_license",
type: "varchar",
length: 100,
nullable: true,
})
businessLicense?: string | null;
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
}

View File

@@ -1,79 +1,110 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { ExternalProfile } from './external-profile.entity';
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, OneToMany } from "typeorm";
import { ExternalProfile } from "./external-profile.entity";
import { CompanyProfile } from "./company-profile.entity";
export enum CompanyType {
Customer = 'customer',
Forwarder = 'forwarder',
Transporter = 'transporter',
Broker = 'broker',
Customer = "customer",
FreightForwarder = "freight_forwarder",
DJFreightForwarder = "dj_freight_forwarder",
Transporter = "transporter",
}
export enum CompanyStatus {
Active = 'active',
Pending = 'pending',
Suspended = 'suspended',
Blacklisted = 'blacklisted',
Active = "active",
Pending = "pending",
Suspended = "suspended",
Blacklisted = "blacklisted",
}
@Entity({ schema: 'freight', name: 'companies' })
@Index(['tin'])
@Index(['type'])
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
export class Company extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 200 })
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
type!: CompanyType;
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
@Column({
name: "status",
type: "varchar",
length: 32,
default: CompanyStatus.Pending,
})
status!: CompanyStatus;
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
tin!: string;
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
@Column({ name: "vat_number", type: "varchar", length: 50, nullable: true })
vatNumber?: string | null;
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
businessLicense?: string | null;
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
@Column({ name: "fan_number", type: "varchar", length: 16, nullable: true })
fanNumber?: string | null;
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
country!: string;
@Column({ name: 'address', type: 'text', nullable: true })
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
@Column({ name: "phone", type: "varchar", length: 20, nullable: true })
phone?: string | null;
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
email?: string | null;
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
@Column({
name: "contact_person_name",
type: "varchar",
length: 100,
nullable: true,
})
contactPersonName?: string | null;
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
@Column({
name: "contact_person_phone",
type: "varchar",
length: 20,
nullable: true,
})
contactPersonPhone?: string | null;
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
@Column({
name: "general_manager_name",
type: "varchar",
length: 100,
nullable: true,
})
generalManagerName?: string | null;
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
@Column({
name: "general_manager_email",
type: "varchar",
length: 150,
nullable: true,
})
generalManagerEmail?: string | null;
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
@Column({
name: "general_manager_phone",
type: "varchar",
length: 20,
nullable: true,
})
generalManagerPhone?: string | null;
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
website?: string | null;
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
@OneToMany(() => ExternalProfile, (profile) => profile.company)
profiles?: ExternalProfile[];
@OneToMany(() => CompanyProfile, (profile) => profile.company)
companyProfiles?: CompanyProfile[];
}

View File

@@ -1,37 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { Company } from './company.entity';
export enum FFClientRelationship {
ManagedAccount = 'managed_account',
SubAgent = 'sub_agent',
}
@Entity({ schema: 'freight', name: 'ff_clients' })
@Unique(['forwarderCompanyId', 'clientCompanyId'])
@Index(['forwarderCompanyId'])
@Index(['clientCompanyId'])
export class FFClient extends BaseEntity {
@Column({ name: 'forwarder_company_id', type: 'uuid' })
forwarderCompanyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'forwarder_company_id' })
forwarderCompany!: Company;
@Column({ name: 'client_company_id', type: 'uuid' })
clientCompanyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'client_company_id' })
clientCompany!: Company;
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
relationshipType!: FFClientRelationship;
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
canBookOnBehalf!: boolean;
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
canViewDocuments!: boolean;
}

View File

@@ -1,32 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { FFClient } from './entities/ff-client.entity';
@Injectable()
export class FFClientRepository extends BaseRepository<FFClient> {
constructor(
@InjectRepository(FFClient)
repo: Repository<FFClient>,
) {
super(repo);
}
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
return this.repository.find({ where: { forwarderCompanyId } as any });
}
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
return this.repository.find({ where: { clientCompanyId } as any });
}
async findRelationship(
forwarderCompanyId: string,
clientCompanyId: string,
): Promise<FFClient | null> {
return this.repository.findOne({
where: { forwarderCompanyId, clientCompanyId } as any,
});
}
}

View File

@@ -1,86 +0,0 @@
// src/modules/customers/customers.controller.ts
import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Body,
Query,
} from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Controller("customers")
@FreightAdmin()
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
return this.customersService.create(createCustomerDto);
}
@Get()
findAll(): Promise<Customer[]> {
return this.customersService.findAll();
}
@Get("stats")
@ApiOperation({ summary: "Get customer statistics" })
getStats(): Promise<{ total: number; withVatNumber: number }> {
return this.customersService.getStats();
}
@Get("search")
searchByName(@Query("name") name: string): Promise<Customer[]> {
return this.customersService.searchByName(name);
}
@Get("email/:email")
findByEmail(@Param("email") email: string): Promise<Customer> {
return this.customersService.findByEmail(email);
}
@Get("vat/:vatNumber")
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
return this.customersService.findByVatNumber(vatNumber);
}
@Get(":id")
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
return this.customersService.findById(id);
}
// @Get("user/:userId")
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
// return this.customersService.findByUserId(userId);
// }
@Patch(":id")
@ApiOperation({ summary: "Update a customer" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
): Promise<Customer> {
return this.customersService.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Soft-delete a customer" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
return this.customersService.delete(id);
}
}

View File

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

View File

@@ -1,117 +0,0 @@
// import { BaseRepository } from "@edr/api-common";
// import { EntityRepository } from "typeorm";
// src/modules/customers/customers.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
import { Customer } from "./entities/customer.entity";
import { CreateCustomerDto } from "./dto/create-customer.dto";
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
@Injectable()
export class CustomersRepository {
constructor(
@InjectRepository(Customer)
private readonly repository: Repository<Customer>,
) { }
async create(dto: CreateCustomerDto): Promise<Customer> {
const customer = this.repository.create(dto);
return await this.repository.save(customer);
}
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
return await this.repository.find(options);
}
async findById(id: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
}
async findByUserId(userId: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
}
async findByEmail(email: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
}
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
}
async findByName(name: string): Promise<Customer[]> {
return await this.repository
.createQueryBuilder("customer")
.where("customer.companyName ILIKE :name", { name: `%${name}%` })
.getMany();
}
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
if (!email && !vatNumber) return null;
const queryBuilder = this.repository.createQueryBuilder('customer');
if (email && vatNumber) {
queryBuilder.where('customer.email = :email', { email })
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
} else if (email) {
queryBuilder.where('customer.email = :email', { email });
} else if (vatNumber) {
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
}
return await queryBuilder.getOne();
}
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
await this.repository.update(id, updates);
return this.findById(id);
}
async delete(id: string): Promise<boolean> {
const result = await this.repository.delete(id);
return (result.affected ?? 0) > 0;
}
async count(where?: any): Promise<number> {
if (where?.createdAt) {
const result = await this.repository
.createQueryBuilder('customer')
.where('customer.createdAt >= :date', { date: where.createdAt })
.getCount();
return result;
}
return await this.repository.count();
}
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
const queryBuilder = this.repository.createQueryBuilder('customer')
.where('customer.email = :email', { email });
if (vatNumber) {
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
}
const count = await queryBuilder.getCount();
return count > 0;
}
async countWithVatNumber(): Promise<number> {
const count = await this.repository
.createQueryBuilder('customer')
.where('customer.vatNumber IS NOT NULL')
.andWhere("customer.vatNumber != ''")
.getCount();
return count;
}
getRepository(): Repository<Customer> {
return this.repository;
}
softDelete(id: string): any {
return id;
}
}

View File

@@ -1,140 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { CustomersRepository } from "./customers.repository";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
/** Create a new customer */
async create(dto: CreateCustomerDto): Promise<Customer> {
const exists = await this.customersRepository.existsByUniqueFields(
dto.email,
dto.vatNumber,
);
if (exists) {
throw new ConflictException(
"Customer with same email or VAT number already exists",
);
}
if (dto.vatNumber && dto.vatNumber.length !== 10) {
throw new BadRequestException("VAT number must be exactly 10 digits");
}
return this.customersRepository.create(dto);
}
/** Get all customers */
findAll(): Promise<Customer[]> {
return this.customersRepository.findAll({
order: { companyName: "ASC" },
});
}
/** Get customer by ID */
async findById(id: string): Promise<Customer> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
throw new NotFoundException(`Customer with ID ${id} not found`);
}
return customer;
}
// async findByUserId(userId: string): Promise<Customer> {
// const customer = await this.customersRepository.findByUserId(userId);
// if (!customer) {
// throw new NotFoundException(`Customer with ID ${userId} not found`);
// }
// return customer;
//}
/** Get customer by email */
async findByEmail(email: string): Promise<Customer> {
const customer = await this.customersRepository.findByEmail(email);
if (!customer) {
throw new NotFoundException(`Customer with email ${email} not found`);
}
return customer;
}
/** Get customer by VAT number */
async findByVatNumber(vatNumber: string): Promise<Customer> {
const customer = await this.customersRepository.findByVatNumber(vatNumber);
if (!customer) {
throw new NotFoundException(
`Customer with VAT number ${vatNumber} not found`,
);
}
return customer;
}
/** Search customers by name */
searchByName(name: string): Promise<Customer[]> {
return this.customersRepository.findByName(name);
}
/** Update customer */
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
await this.findById(id);
// Validate VAT number if provided
if (dto.vatNumber && dto.vatNumber.length !== 10) {
throw new BadRequestException("VAT number must be exactly 10 digits");
}
// // Check email conflict
// if (dto.email) {
// const existing = await this.customersRepository.findByEmail(dto.email);
// // if (existing && existing.userId !== id) {
// // throw new ConflictException(
// // `Customer with email "${dto.email}" already exists`,
// // );
// // }
// }
const updated = await this.customersRepository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Customer ${id} not found`);
}
return updated;
}
/** Delete customer (soft delete) */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.customersRepository.softDelete(id);
}
/** Get customer statistics */
async getStats(): Promise<{ total: number; withVatNumber: number }> {
const total = await this.customersRepository.count();
const withVatNumber = await this.customersRepository.countWithVatNumber();
return { total, withVatNumber };
}
delete(id: string): any {
return id;
}
}

View File

@@ -1,156 +0,0 @@
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
IsNotEmpty,
Length,
Matches,
} from "class-validator";
// Enums
export enum CustomerStatusDto {
Active = "Active",
Pending = "Pending",
Inactive = "Inactive",
}
export enum CustomerTypeDto {
Importer = "Importer",
Exporter = "Exporter",
Supplier = "Supplier",
}
// DTO
export class CreateCustomerDto {
// Basic identity
@IsString()
@IsNotEmpty()
userId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName!: string;
@IsEmail()
@IsNotEmpty()
email!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
phone!: string;
// Company info
@IsString()
@IsNotEmpty()
@MaxLength(200)
companyName!: string;
@IsEmail()
@IsNotEmpty()
companyEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
companyPhone!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
companyLocation!: string;
@IsString()
@IsNotEmpty()
companyAddress!: string;
// Classification
@IsOptional()
@IsEnum(CustomerTypeDto)
customerType?: CustomerTypeDto;
@IsOptional()
@IsEnum(CustomerStatusDto)
status?: CustomerStatusDto;
// Legal identifiers
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
tinNumber!: string;
@IsString()
@IsNotEmpty()
@Length(16, 16)
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
fanNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(50)
vatNumber!: string;
// Contact person
@IsString()
@IsNotEmpty()
@MaxLength(100)
contactPersonName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
contactPersonPhone!: string;
// Management
@IsString()
@IsNotEmpty()
@MaxLength(100)
generalManagerName!: string;
@IsEmail()
@IsNotEmpty()
generalManagerEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
generalManagerPhone!: string;
// POA (Power of Attorney)
@IsOptional()
@IsString()
@MaxLength(100)
poaName?: string;
@IsOptional()
@IsString()
@MaxLength(20)
poaPhone?: string;
@IsOptional()
@IsString()
poaAddress?: string;
@IsOptional()
@IsEmail()
poaEmail?: string;
@IsOptional()
@IsString()
@MaxLength(100)
poaLocation?: string;
// Extra
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -1,60 +0,0 @@
// src/modules/customers/dto/response-customer.dto.ts
import { Customer } from '../entities/customer.entity';
export class ResponseCustomerDto {
//UserId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
companyLocation: string;
companyAddress: string;
contactPersonName: string;
contactPersonPhone: string;
tinNumber: string;
vatNumber?: string;
fanNumber: string;
generalManagerName: string;
generalManagerEmail: string;
generalManagerPhone: string;
poaName?: string;
poaPhone?: string;
poaAddress?: string;
poaEmail?: string;
poaLocation?: string;
notes?: string;
createdAt: Date;
updatedAt: Date;
constructor(customer: Customer) {
//this.UserId = customer.userId;
this.firstName = customer.firstName;
this.lastName = customer.lastName;
this.email = customer.email;
this.phone = customer.phone;
this.companyName = customer.companyName;
this.companyEmail = customer.companyEmail;
this.companyPhone = customer.companyPhone;
this.companyLocation = customer.companyLocation;
this.companyAddress = customer.companyAddress;
this.contactPersonName = customer.contactPersonName;
this.contactPersonPhone = customer.contactPersonPhone;
this.tinNumber = customer.tinNumber;
this.vatNumber = customer.vatNumber ?? undefined;
this.fanNumber = customer.fanNumber;
this.generalManagerName = customer.generalManagerName;
this.generalManagerEmail = customer.generalManagerEmail;
this.generalManagerPhone = customer.generalManagerPhone;
this.poaName = customer.poaName ?? '';
this.poaPhone = customer.poaPhone ?? '';
this.poaAddress = customer.poaAddress ?? '';
this.poaEmail = customer.poaEmail ?? '';
this.poaLocation = customer.poaLocation ?? '';
this.notes = customer.notes ?? '';
this.createdAt = customer.createdAt;
this.updatedAt = customer.updatedAt;
}
}

View File

@@ -1,9 +0,0 @@
// src/modules/customers/dto/update-customer.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateCustomerDto } from './create-customer.dto';
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
email?: string;
vatNumber?: string;
// Add any other properties you need to access directly
}

View File

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

View File

@@ -0,0 +1,18 @@
import type { FacilityStatus, FacilityType } from '../entities/facility.entity';
export class CreateFacilityDto {
code!: string;
name!: string;
description?: string;
facilityType!: FacilityType;
facilityStatus?: FacilityStatus;
locationName?: string;
country?: string;
city?: string;
address?: string;
latitude?: number;
longitude?: number;
capacity?: number;
isActive?: boolean;
notes?: string;
}

View File

@@ -0,0 +1,18 @@
import type { FacilityStatus, FacilityType } from '../entities/facility.entity';
export class UpdateFacilityDto {
code?: string;
name?: string;
description?: string;
facilityType?: FacilityType;
facilityStatus?: FacilityStatus;
locationName?: string;
country?: string;
city?: string;
address?: string;
latitude?: number;
longitude?: number;
capacity?: number;
isActive?: boolean;
notes?: string;
}

View File

@@ -0,0 +1,60 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Warehouse } from '../../warehouses/entities/warehouse.entity';
export const FACILITY_TYPES = ['PORT', 'DRY_PORT', 'TERMINAL', 'RAIL_YARD', 'WAREHOUSE_COMPLEX'] as const;
export type FacilityType = (typeof FACILITY_TYPES)[number];
export const FACILITY_STATUSES = ['ACTIVE', 'INACTIVE', 'UNDER_MAINTENANCE'] as const;
export type FacilityStatus = (typeof FACILITY_STATUSES)[number];
@Entity({ schema: 'freight', name: 'facilities' })
@Index(['code'], { unique: true })
@Index(['facilityStatus'])
export class Facility extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 160 })
name!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'facility_type', type: 'varchar', length: 32 })
facilityType!: FacilityType;
@Column({ name: 'facility_status', type: 'varchar', length: 32, default: 'ACTIVE' })
facilityStatus!: FacilityStatus;
@Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true })
locationName?: string | null;
@Column({ name: 'country', type: 'varchar', length: 100, nullable: true })
country?: string | null;
@Column({ name: 'city', type: 'varchar', length: 100, nullable: true })
city?: string | null;
@Column({ name: 'address', type: 'text', nullable: true })
address?: string | null;
@Column({ name: 'latitude', type: 'numeric', precision: 10, scale: 8, nullable: true })
latitude?: number | null;
@Column({ name: 'longitude', type: 'numeric', precision: 11, scale: 8, nullable: true })
longitude?: number | null;
@Column({ name: 'capacity', type: 'numeric', precision: 14, scale: 3, nullable: true })
capacity?: number | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
@OneToMany(() => Warehouse, (warehouse) => warehouse.facility)
warehouses?: Warehouse[];
}

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateFacilityDto } from './dto/create-facility.dto';
import { UpdateFacilityDto } from './dto/update-facility.dto';
import { Facility } from './entities/facility.entity';
import { FacilitiesService } from './facilities.service';
@ApiTags('Facilities')
@Controller('facilities')
export class FacilitiesController {
constructor(private readonly facilitiesService: FacilitiesService) {}
@Post()
@ApiOperation({ summary: 'Create a new facility' })
async create(@Body() createFacilityDto: CreateFacilityDto): Promise<Facility> {
return this.facilitiesService.create(createFacilityDto);
}
@Get()
@ApiOperation({ summary: 'List all facilities' })
async findAll(): Promise<Facility[]> {
return this.facilitiesService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get a facility by ID' })
async findOne(@Param('id') id: string): Promise<Facility | null> {
return this.facilitiesService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a facility' })
async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
return this.facilitiesService.update(id, updateFacilityDto);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Delete a facility (soft delete)' })
async remove(@Param('id') id: string): Promise<void> {
return this.facilitiesService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Facility } from './entities/facility.entity';
import { FacilitiesController } from './facilities.controller';
import { FacilitiesRepository } from './facilities.repository';
import { FacilitiesService } from './facilities.service';
@Module({
imports: [TypeOrmModule.forFeature([Facility])],
controllers: [FacilitiesController],
providers: [FacilitiesService, FacilitiesRepository],
exports: [FacilitiesService, FacilitiesRepository],
})
export class FacilitiesModule {}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Facility } from './entities/facility.entity';
@Injectable()
export class FacilitiesRepository extends BaseRepository<Facility> {
constructor(@InjectRepository(Facility) repository: Repository<Facility>) {
super(repository);
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { CreateFacilityDto } from './dto/create-facility.dto';
import { UpdateFacilityDto } from './dto/update-facility.dto';
import { Facility } from './entities/facility.entity';
import { FacilitiesRepository } from './facilities.repository';
@Injectable()
export class FacilitiesService {
constructor(private readonly facilitiesRepository: FacilitiesRepository) {}
async create(createFacilityDto: CreateFacilityDto): Promise<Facility> {
return this.facilitiesRepository.create(createFacilityDto);
}
async findAll(): Promise<Facility[]> {
return this.facilitiesRepository.findAll({ relations: ['warehouses'] });
}
async findOne(id: string): Promise<Facility | null> {
return this.facilitiesRepository.findById(id);
}
async update(id: string, updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
return this.facilitiesRepository.update(id, updateFacilityDto);
}
async remove(id: string): Promise<void> {
return this.facilitiesRepository.softDelete(id);
}
}

View File

@@ -6,6 +6,9 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
export const LOCOMOTIVE_STATUSES = [
'AVAILABLE',
'UNAVAILABLE',
'IMPORT_READY',
'EXPORT_READY',
'ASSIGNED',
'MAINTENANCE',
'OUT_OF_SERVICE',

View File

@@ -1,25 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Employee } from '@tria-plc/iamapi-common';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Employee } from "@tria-plc/iamapi-common";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { Customer } from '../customers/entities/customer.entity';
import { PaymentEntity } from '../payment/entities/payment.entity';
import { Train } from '../trains/entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { OverviewController } from './overview.controller';
import { OverviewRepository } from './overview.repository';
import { OverviewService } from './overview.service';
import { Booking } from "../bookings/entities/booking.entity";
import { Cargo } from "../cargoes/entities/cargoes.entity";
import { Container } from "../container-management/entities/container.entity";
import { Company } from "../companies/entities/company.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { Train } from "../trains/entities/train.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import { OverviewController } from "./overview.controller";
import { OverviewRepository } from "./overview.repository";
import { OverviewService } from "./overview.service";
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
PaymentEntity,
Customer,
Company,
Train,
Wagon,
Container,
@@ -31,4 +31,4 @@ import { OverviewService } from './overview.service';
controllers: [OverviewController],
providers: [OverviewService, OverviewRepository],
})
export class OverviewModule {}
export class OverviewModule { }

View File

@@ -1,24 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import { Employee } from '@tria-plc/iamapi-common';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Freight } from '@edr/types';
import { Repository, ObjectLiteral } from 'typeorm';
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { Employee } from "@tria-plc/iamapi-common";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Freight } from "@edr/types";
import { Repository, ObjectLiteral } from "typeorm";
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { Customer } from '../customers/entities/customer.entity';
import { PaymentEntity } from '../payment/entities/payment.entity';
import { Train } from '../trains/entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { Booking } from "../bookings/entities/booking.entity";
import { Cargo } from "../cargoes/entities/cargoes.entity";
import { Container } from "../container-management/entities/container.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { Train } from "../trains/entities/train.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import {
OVERVIEW_CLOSED_STATUSES,
OVERVIEW_IN_APPROVAL_STATUSES,
OVERVIEW_NEEDS_ACTION_STATUSES,
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
} from './overview.constants';
} from "./overview.constants";
import { Company } from "../companies/entities/company.entity";
export type OverviewBookingKpisRow = {
totalActive: number;
@@ -46,8 +46,8 @@ export class OverviewRepository {
private readonly bookingRepository: Repository<Booking>,
@InjectRepository(PaymentEntity)
private readonly paymentRepository: Repository<PaymentEntity>,
@InjectRepository(Customer)
private readonly customerRepository: Repository<Customer>,
@InjectRepository(Company)
private readonly companyRepository: Repository<Company>,
@InjectRepository(Train)
private readonly trainRepository: Repository<Train>,
@InjectRepository(Wagon)
@@ -60,32 +60,32 @@ export class OverviewRepository {
private readonly employeeRepository: Repository<Employee>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
) { }
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
const row = await this.bookingRepository
.createQueryBuilder('booking')
.createQueryBuilder("booking")
.select(
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
'totalActive',
"totalActive",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
'needsAction',
"needsAction",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
'urgent',
"urgent",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
'inApproval',
"inApproval",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
'submittedToday',
"submittedToday",
)
.where('booking.deleted_at IS NULL')
.where("booking.deleted_at IS NULL")
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
@@ -112,9 +112,9 @@ export class OverviewRepository {
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
await Promise.all([
this.trainRepository
.createQueryBuilder('train')
.where('train.deleted_at IS NULL')
.andWhere('train.status IN (:...statuses)', {
.createQueryBuilder("train")
.where("train.deleted_at IS NULL")
.andWhere("train.status IN (:...statuses)", {
statuses: [
Freight.TrainStatus.InService,
Freight.TrainStatus.Scheduled,
@@ -122,39 +122,46 @@ export class OverviewRepository {
})
.getCount(),
this.wagonRepository
.createQueryBuilder('wagon')
.where('wagon.deleted_at IS NULL')
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
.createQueryBuilder("wagon")
.where("wagon.deleted_at IS NULL")
.andWhere("wagon.status = :status", {
status: Freight.WagonStatus.Available,
})
.getCount(),
this.containerRepository
.createQueryBuilder('container')
.where('container.deleted_at IS NULL')
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
.createQueryBuilder("container")
.where("container.deleted_at IS NULL")
.andWhere("container.status = :status", { status: "IN_TRANSIT" })
.getCount(),
this.cargoRepository
.createQueryBuilder('cargo')
.where('cargo.deleted_at IS NULL')
.andWhere('cargo.status IN (:...statuses)', {
statuses: ['LOADED', 'IN_TRANSIT'],
.createQueryBuilder("cargo")
.where("cargo.deleted_at IS NULL")
.andWhere("cargo.status IN (:...statuses)", {
statuses: ["LOADED", "IN_TRANSIT"],
})
.getCount(),
]);
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
return {
trainsActive,
wagonsAvailable,
containersInTransit,
cargoesLoaded,
};
}
async getCustomerKpis(): Promise<{
totalCustomers: number;
newCustomersThisMonth: number;
}> {
const row = await this.customerRepository
.createQueryBuilder('customer')
.select('COUNT(*)::int', 'totalCustomers')
const row = await this.companyRepository
.createQueryBuilder("customer")
.select("COUNT(*)::int", "totalCustomers")
.addSelect(
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
'newCustomersThisMonth',
"newCustomersThisMonth",
)
.where('customer.deleted_at IS NULL')
.where("customer.deleted_at IS NULL")
.getRawOne<Record<string, string>>();
return {
@@ -170,26 +177,26 @@ export class OverviewRepository {
successfulPaymentsMtd: number;
}> {
const revenueRow = await this.paymentRepository
.createQueryBuilder('payment')
.createQueryBuilder("payment")
.select(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
'revenueMtdEtb',
"revenueMtdEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
'revenueMtdUsd',
"revenueMtdUsd",
)
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
.where('payment.status = :status', { status: 'success' })
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.getRawOne<Record<string, string>>();
const pendingPayments = await this.paymentRepository
.createQueryBuilder('payment')
.where('payment.status IN (:...statuses)', {
statuses: ['action-required', 'processing'],
.createQueryBuilder("payment")
.where("payment.status IN (:...statuses)", {
statuses: ["action-required", "processing"],
})
.getCount();
@@ -201,7 +208,10 @@ export class OverviewRepository {
};
}
async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
async getStaffKpis(): Promise<{
activeEmployees: number;
activeUsers: number;
}> {
const [activeEmployees, activeUsers] = await Promise.all([
this.employeeRepository.count({
where: { isCurrent: true },
@@ -217,15 +227,17 @@ export class OverviewRepository {
return { activeEmployees, activeUsers };
}
async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
async getBookingTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('booking.created_at::date')
.orderBy('booking.created_at::date', 'ASC')
.groupBy("booking.created_at::date")
.orderBy("booking.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -236,11 +248,11 @@ export class OverviewRepository {
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.groupBy('booking.status')
.createQueryBuilder("booking")
.select("booking.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.groupBy("booking.status")
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(
@@ -252,26 +264,26 @@ export class OverviewRepository {
days: number,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.createQueryBuilder("payment")
.select(
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
'date',
"date",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
'amountEtb',
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
'amountUsd',
"amountUsd",
)
.where('payment.status = :status', { status: 'success' })
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
return rows.map((row) => ({
@@ -283,18 +295,18 @@ export class OverviewRepository {
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.leftJoin('booking.company', 'company')
.select('booking.id', 'id')
.addSelect('booking.reference', 'reference')
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
.addSelect('booking.status', 'status')
.addSelect('booking.priority_score', 'priorityScore')
.addSelect('booking.total_amount', 'totalAmount')
.addSelect('booking.payment_currency', 'paymentCurrency')
.addSelect('booking.created_at', 'createdAt')
.where('booking.deleted_at IS NULL')
.orderBy('booking.created_at', 'DESC')
.createQueryBuilder("booking")
.leftJoin("booking.company", "company")
.select("booking.id", "id")
.addSelect("booking.reference", "reference")
.addSelect("COALESCE(company.name, '—')", "customerLabel")
.addSelect("booking.status", "status")
.addSelect("booking.priority_score", "priorityScore")
.addSelect("booking.total_amount", "totalAmount")
.addSelect("booking.payment_currency", "paymentCurrency")
.addSelect("booking.created_at", "createdAt")
.where("booking.deleted_at IS NULL")
.orderBy("booking.created_at", "DESC")
.limit(limit)
.getRawMany<{
id: string;
@@ -319,15 +331,17 @@ export class OverviewRepository {
}));
}
async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
async getBookingsByFreightType(): Promise<
{ label: string; count: number }[]
> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.freight_type', 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select("booking.freight_type", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('booking.freight_type')
.orderBy('count', 'DESC')
.groupBy("booking.freight_type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -338,13 +352,13 @@ export class OverviewRepository {
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.payment_currency', 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select("booking.payment_currency", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('booking.payment_currency')
.orderBy('count', 'DESC')
.groupBy("booking.payment_currency")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -355,11 +369,11 @@ export class OverviewRepository {
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('payment.status')
.orderBy('count', 'DESC')
.createQueryBuilder("payment")
.select("payment.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("payment.status")
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -372,20 +386,25 @@ export class OverviewRepository {
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.method', 'method')
.addSelect('COUNT(*)::int', 'count')
.createQueryBuilder("payment")
.select("payment.method", "method")
.addSelect("COUNT(*)::int", "count")
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
'amountEtb',
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
'amountUsd',
"amountUsd",
)
.groupBy('payment.method')
.orderBy('count', 'DESC')
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
.groupBy("payment.method")
.orderBy("count", "DESC")
.getRawMany<{
method: string;
count: string;
amountEtb: string;
amountUsd: string;
}>();
return rows.map((row) => ({
method: row.method,
@@ -395,16 +414,18 @@ export class OverviewRepository {
}));
}
async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
async getRevenueByCurrency(): Promise<
{ currency: string; amount: number }[]
> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.currency', 'currency')
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
.where('payment.status = :status', { status: 'success' })
.createQueryBuilder("payment")
.select("payment.currency", "currency")
.addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.groupBy('payment.currency')
.groupBy("payment.currency")
.getRawMany<{ currency: string; amount: string }>();
return rows.map((row) => ({
@@ -413,20 +434,28 @@ export class OverviewRepository {
}));
}
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.trainRepository, 'train');
async getTrainStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.trainRepository, "train");
}
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.wagonRepository, 'wagon');
async getWagonStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.wagonRepository, "wagon");
}
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.containerRepository, 'container');
async getContainerStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.containerRepository, "container");
}
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.cargoRepository, 'cargo');
async getCargoStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.cargoRepository, "cargo");
}
private async statusBreakdown(
@@ -435,11 +464,11 @@ export class OverviewRepository {
): Promise<{ status: string; count: number }[]> {
const rows = await repository
.createQueryBuilder(alias)
.select(`${alias}.status`, 'status')
.addSelect('COUNT(*)::int', 'count')
.select(`${alias}.status`, "status")
.addSelect("COUNT(*)::int", "count")
.where(`${alias}.deleted_at IS NULL`)
.groupBy(`${alias}.status`)
.orderBy('count', 'DESC')
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -448,15 +477,19 @@ export class OverviewRepository {
}));
}
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
const rows = await this.customerRepository
.createQueryBuilder('customer')
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('customer.deleted_at IS NULL')
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('customer.created_at::date')
.orderBy('customer.created_at::date', 'ASC')
async getCustomerGrowthTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.companyRepository
.createQueryBuilder("customer")
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("customer.deleted_at IS NULL")
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, {
days,
})
.groupBy("customer.created_at::date")
.orderBy("customer.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -466,13 +499,16 @@ export class OverviewRepository {
}
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
const rows = await this.customerRepository
.createQueryBuilder('customer')
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
.addSelect('COUNT(*)::int', 'count')
.where('customer.deleted_at IS NULL')
.groupBy('customer.customer_type')
.orderBy('count', 'DESC')
const rows = await this.companyRepository
.createQueryBuilder("customer")
.select(
`COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
"label",
)
.addSelect("COUNT(*)::int", "count")
.where("customer.deleted_at IS NULL")
.groupBy("customer.type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -481,16 +517,18 @@ export class OverviewRepository {
}));
}
async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
async getTopCustomersByBookings(
limit: number,
): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.leftJoin('booking.company', 'company')
.select(`COALESCE(company.name, 'Unknown')`, 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.leftJoin("booking.company", "company")
.select(`COALESCE(company.name, 'Unknown')`, "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('company.name')
.orderBy('count', 'DESC')
.groupBy("company.name")
.orderBy("count", "DESC")
.limit(limit)
.getRawMany<{ label: string; count: string }>();
@@ -502,11 +540,11 @@ export class OverviewRepository {
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.userRepository
.createQueryBuilder('user')
.select('user.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('user.status')
.orderBy('count', 'DESC')
.createQueryBuilder("user")
.select("user.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("user.status")
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -515,15 +553,19 @@ export class OverviewRepository {
}));
}
async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
async getEmployeeGrowthTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.employeeRepository
.createQueryBuilder('employee')
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('employee.is_current = true')
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('employee.created_at::date')
.orderBy('employee.created_at::date', 'ASC')
.createQueryBuilder("employee")
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("employee.is_current = true")
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
days,
})
.groupBy("employee.created_at::date")
.orderBy("employee.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -538,16 +580,16 @@ export class OverviewRepository {
where: { isActive: true, status: EUserStatus.ACCEPTED },
}),
this.userRepository
.createQueryBuilder('user')
.where('user.is_active = false OR user.status != :status', {
.createQueryBuilder("user")
.where("user.is_active = false OR user.status != :status", {
status: EUserStatus.ACCEPTED,
})
.getCount(),
]);
return [
{ label: 'Active', count: active },
{ label: 'Inactive', count: inactive },
{ label: "Active", count: active },
{ label: "Inactive", count: inactive },
];
}
}

View File

@@ -18,7 +18,8 @@ import {
export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = (
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
// process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -145,9 +145,7 @@ export class PaymentService {
.findOneBy({ id: dto.bookingId });
if (!booking) throw new NotFoundException("Booking not found");
console.log("bookingbooking",booking)
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
console.log("amountminor",amountMinor)
const amountMinor = Math.round(Number(booking.totalAmount));
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
@@ -159,8 +157,8 @@ export class PaymentService {
provider: dto.method as unknown as ProviderMethod,
platform: dto.platform,
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
returnUrl:'https://edrfreight.triaplc.com/payment/success',
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
});
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);

View File

@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// A train can be rescheduled (with or without bookings) at any time UNLESS it
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
if (schedule.status === TrainScheduleStatus.Dispatched) {
throw new BadRequestException('Cannot reschedule a dispatched train');
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
}
if (schedule.status === TrainScheduleStatus.Arrived) {
throw new BadRequestException('Cannot reschedule a train that has already arrived');
}
if (schedule.status === TrainScheduleStatus.Cancelled) {
throw new BadRequestException('Cannot reschedule a cancelled train');
}
const currentOnSchedule = (schedule.scheduleBookings ?? [])
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
}
}
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
bookingIds: dto.finalBookingIds,
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
});
// A train can be rescheduled even with no bookings (e.g. moved for
// maintenance). assignBookingsToSchedule requires at least one booking, so
// only call it when something is actually being (re)assigned — the new
// departure date above is the meaningful change for an empty train. The
// empty-train branch returns the same schedule-detail shape as the assign
// path so callers get a consistent response.
const assignResult = dto.finalBookingIds.length
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
bookingIds: dto.finalBookingIds,
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
})
: {
...(await this.trainSchedulingService.getContainerTrainScheduleById(
scheduleId,
)),
warnings: [] as string[],
deferredBookings: [] as unknown[],
};
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,

View File

@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
};
}
/**
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
* key for day-level booking pools — it must match the day the portal calendar
* renders, so always derive day keys through this (never `toISOString().slice`).
*/
export function eatDay(date: Date): string {
const { year, month, day } = eatParts(date);
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
function eatToUtc(
year: number,

View File

@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
let bookingsRepository: {
findPaidUnlinkedForSchedule: jest.Mock;
findBatchPool: jest.Mock;
findBatchPoolByRouteDay: jest.Mock;
findReservedForSchedule: jest.Mock;
update: jest.Mock;
};
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
getBookableSchedules: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let notifier: {
payNow: jest.Mock;
secured: jest.Mock;
expired: jest.Mock;
unplaced: jest.Mock;
};
beforeEach(() => {
bookingsRepository = {
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
findBatchPool: jest.fn().mockResolvedValue([]),
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
findReservedForSchedule: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [],
violations: [],
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
};
const bookingRepo = {
findOne: jest.fn().mockResolvedValue(paidBooking),
update: jest.fn().mockResolvedValue(undefined),
// WagonType.find() / global-rules find() fall back to defaults when empty.
find: jest.fn().mockResolvedValue([]),
};
dataSource = {
getRepository: jest.fn().mockReturnValue(bookingRepo),
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
};
notifier = {
payNow: jest.fn(),
secured: jest.fn(),
expired: jest.fn(),
unplaced: jest.fn(),
};
service = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
);
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(fillOrder).toBeLessThan(reconcileOrder);
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('fillRouteDay — day-level distribution', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const day = '2026-06-20';
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
const trainA = 'train-a';
const trainB = 'train-b';
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
const commercial = (id: string, priority: number): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
bookingContainers: [],
}) as unknown as Booking;
beforeEach(() => {
// Two OPEN trains on the same route + day, train A earlier than train B.
trainSchedulingService.getBookableSchedules.mockResolvedValue([
{
id: trainA,
scheduleDate: '2026-06-20T06:00:00.000Z',
bookingWindowStatus: 'OPEN',
},
{
id: trainB,
scheduleDate: '2026-06-20T09:00:00.000Z',
bookingWindowStatus: 'OPEN',
},
]);
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
Promise.resolve({
id,
maxWagons: 1,
bookingWindowStatus: 'OPEN',
trainSetId: `set-${id}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
}),
);
});
it('spills overflow to the next train by priority, then reports unplaced', async () => {
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
commercial('hi', 30),
commercial('mid', 20),
commercial('lo', 10),
]);
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
originYardId,
destinationYardId,
day,
);
// Both trains were processed.
expect(touched).toEqual([trainA, trainB]);
// Highest priority reserved on train A, next on train B (commercial → reserve).
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
expect(reservedOn).toEqual(['hi', 'mid']);
// The third booking fits no train and is reported unplaced (and only it).
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
});
it('reserves the chosen train id on each commercial booking', async () => {
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
expect(bookingsRepository.update).toHaveBeenCalledWith(
'hi',
expect.objectContaining({
trainScheduleId: trainA,
status: 'SELECTED_FOR_BATCH',
}),
);
});
});
});

View File

@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { groupBookingsIntoBoardWindows } from './batch-window.util';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import {
BATCH_CRON,
BATCH_TIMEZONE,
@@ -42,6 +42,14 @@ interface Capacity {
lengthMeters: number;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
destinationYardId: string;
/** EAT calendar day, `yyyy-MM-dd`. */
day: string;
}
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
for (const s of open) {
const groups = await this.openRouteDayGroups();
for (const group of groups) {
try {
await this.processSchedule(s.id);
await this.processRouteDay(group);
} catch (err) {
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
this.logger.warn(
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
);
}
}
const reserved = await this.dataSource
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
/**
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
* (contract sign, payment). Day-level pooling distributes across all of that
* day's trains, so a single schedule id maps to its whole route-day group.
*/
enqueueScheduleProcessing(scheduleId: string): void {
void this.processSchedule(scheduleId).catch((err) =>
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
this.logger.error(
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
),
);
}
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule?.scheduledDepartureDate) return;
await this.processRouteDay({
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
});
}
/**
* Day-level pipeline: distribute the (route, day) pool across all its trains,
* then settle / reconcile / assign wagons per schedule (those steps stay
* schedule-scoped — only the fill is day-level).
*/
async processRouteDay(group: RouteDayGroup): Promise<void> {
const scheduleIds = await this.fillRouteDay(
group.originYardId,
group.destinationYardId,
group.day,
);
for (const scheduleId of scheduleIds) {
await this.settleDueReservations(scheduleId);
await this.reconcilePaidUnlinked(scheduleId);
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
}
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
async processSchedule(scheduleId: string): Promise<void> {
await this.fillSchedule(scheduleId);
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
const groups = new Map<string, RouteDayGroup>();
for (const s of open) {
if (!s.scheduledDepartureDate) continue;
const day = eatDay(s.scheduledDepartureDate);
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
if (!groups.has(key)) {
groups.set(key, {
originYardId: s.originStationId,
destinationYardId: s.destinationStationId,
day,
});
}
}
return [...groups.values()];
}
private groupLabel(group: RouteDayGroup): string {
return `${group.originYardId}${group.destinationYardId} on ${group.day}`;
}
/**
* Idempotent: link a paid batch booking to its schedule and assign wagons.
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
for (const s of open) {
const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
for (const group of groups) {
try {
await this.processSchedule(s.id);
await this.processRouteDay(group);
} catch (err) {
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
this.logger.error(
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
);
}
}
}
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov');
} else {
await this.reserve(booking);
await this.reserve(booking, scheduleId);
armed = true;
}
budget = this.subtract(budget, need);
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId);
}
/**
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
* priority, filling each train (earliest departure first) until it's full and
* spilling overflow to the next. Government bookings that fit no train preempt
* lower-priority commercial; bookings that fit no train at all stay pending and
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
* (or that had remaining pool work) so the caller can settle them per-schedule.
*/
async fillRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<string[]> {
// The day's OPEN bookable schedules on this exact corridor, earliest first.
const bookable = await this.trainSchedulingService.getBookableSchedules(
originYardId,
destinationYardId,
);
const scheduleIds = bookable
.filter(
(s) =>
s.bookingWindowStatus === 'OPEN' &&
s.scheduleDate != null &&
eatDay(new Date(s.scheduleDate)) === day,
)
.sort(
(a, b) =>
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
)
.map((s) => s.id);
if (scheduleIds.length === 0) return [];
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
// Live per-schedule budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
for (const id of scheduleIds) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
continue;
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
originYardId,
destinationYardId,
day,
);
for (const booking of pool) {
const need = this.needFor(booking, wagonLengths);
// First train (earliest departure) that fits this booking as-is.
let target = trains.find((t) => this.fits(need, t.budget));
if (!target && booking.isGovernment) {
// Government booking fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
if (this.fits(need, t.budget)) {
target = t;
break;
}
}
}
if (!target) {
// Fits no train this day — stays in the pool, retried next batch.
this.notifier.unplaced(booking, day);
continue;
}
if (booking.isGovernment) {
await this.allocate(target.id, booking, 'gov');
} else {
await this.reserve(booking, target.id);
target.armed = true;
}
target.budget = this.subtract(target.budget, need);
}
for (const t of trains) {
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
return trains.map((t) => t.id);
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
// ---- mutations ------------------------------------------------------------
/** Reserve capacity for a commercial booking and open its pay window. */
private async reserve(booking: Booking): Promise<void> {
/**
* Reserve capacity for a commercial booking on a specific train and open its
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
* which is all keyed off `booking.trainScheduleId`, can find the train — with
* day-level pooling the booking arrives here with `trainScheduleId` still null,
* so the engine sets it as it picks the train.
*/
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
const now = new Date();
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH',
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
await this.notifier.payNow(booking, deadline);
}
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId);
}
/** Expire an unpaid reservation and free its capacity. */
/**
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train
* it failed to pay for — it's back in the day pool for staff to act on.
*/
private async expire(booking: Booking): Promise<void> {
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
this.notifier.expired(booking);
}

View File

@@ -67,6 +67,17 @@ export class BookingNotifierService {
);
}
/**
* Staff-facing warning when a pooled booking fits no train on its chosen day.
* It stays pending and is retried next batch; staff can add capacity or pin it
* to a train manually. Mirrors {@link scheduleFull} — no customer notification.
*/
unplaced(b: Booking, day: string): void {
this.logger.warn(
`UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
);
}
displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');

View File

@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
export class AvailableDaysQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originYardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationYardId?: string;
}

View File

@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
);
}
@Get("available-days")
// No staff guard: customers hit this while creating a booking to find which
// DAYS have a departure on their route. Day-level pooling — no capacity is
// returned, only the list of bookable days.
@ApiOperation({
summary: "Distinct days with an OPEN same-route departure (day-level pool)",
})
getAvailableDays(@Query() query: AvailableDaysQueryDto) {
return this.trainSchedulingService.getAvailableDays(
query.originYardId,
query.destinationYardId,
);
}
@Get("container/eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: "List eligible container bookings" })

View File

@@ -87,6 +87,7 @@ import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
} from './booking-batch.constants';
import { eatDay } from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
) {}
async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
// resolving the schedule's route + day and filtering on the day instead.
let day: string | undefined;
let originStationId = query.originStationId;
let destinationStationId = query.destinationStationId;
if (query.trainScheduleId) {
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
if (schedule?.scheduledDepartureDate) {
day = eatDay(schedule.scheduledDepartureDate);
originStationId = originStationId ?? schedule.originStationId;
destinationStationId = destinationStationId ?? schedule.destinationStationId;
}
}
const bookings = await this.bookingsRepository.findEligibleForScheduling({
freightType: query.freightType,
originStationId: query.originStationId,
destinationStationId: query.destinationStationId,
originStationId,
destinationStationId,
schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId,
day,
});
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
}
@@ -721,27 +738,37 @@ export class TrainSchedulingService {
: null;
if (route) {
const origin = route.originYard;
const destination = route.destinationYard;
// `route.milestones` is the complete ordered corridor and already includes
// the origin (first) and destination (last) yards — `route.originYardId`
// and `route.destinationYardId` are derived from them. Use the milestones
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
if (milestones.length > 0) {
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
return stations;
}
// Route with no milestones recorded — fall back to its origin/destination.
const origin = route.originYard;
const destination = route.destinationYard;
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i + 1,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
stations.push({
sequenceNo: milestones.length + 1,
sequenceNo: 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
@@ -775,8 +802,16 @@ export class TrainSchedulingService {
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
// Resolve each checkpoint's position by its yard against the canonical
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
// under an older station numbering still line up with the current stations.
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const resolvedSeq = (e: TrainCheckpointEvent) =>
seqByYard.get(e.yardId) ?? e.sequenceNo;
const currentSequenceNo = events.length
? Math.max(...events.map((e) => e.sequenceNo))
? Math.max(...events.map(resolvedSeq))
: -1;
return {
@@ -790,13 +825,19 @@ export class TrainSchedulingService {
actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString()
: null,
scheduledDepartureAt: schedule.scheduledDepartureDate
? schedule.scheduledDepartureDate.toISOString()
: null,
scheduledArrivalAt: schedule.scheduledArrivalDate
? schedule.scheduledArrivalDate.toISOString()
: null,
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
sequenceNo: e.sequenceNo,
sequenceNo: resolvedSeq(e),
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
@@ -1965,6 +2006,33 @@ export class TrainSchedulingService {
return filteredSchedules;
}
/**
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
* departure on the route. Customers pick a DAY (not a train) — so this returns
* only the day strings, no capacity, counts or train info.
*/
async getAvailableDays(
originYardId?: string,
destinationYardId?: string,
): Promise<{ days: string[] }> {
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
const days = new Set<string>();
for (const s of schedules) {
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
}
return { days: [...days].sort() };
}
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
async existsOpenScheduleOnRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<boolean> {
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
return days.includes(day);
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {

View File

@@ -29,6 +29,13 @@ export class TrainSetWagon extends BaseEntity {
@Column({ name: 'wagon_type_id', type: 'uuid' })
wagonTypeId!: string;
@Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true })
physicalWagonId!: string | null;
@ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'physical_wagon_id' })
physicalWagon?: Wagon | null;
@ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons)
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType;
@@ -45,13 +52,6 @@ export class TrainSetWagon extends BaseEntity {
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
assignedWeightTons!: number;
@Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true })
physicalWagonId?: string | null;
@ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'physical_wagon_id' })
physicalWagon?: Wagon | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
status!: string;

View File

@@ -0,0 +1,32 @@
import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
export class CreateVehicleDto {
@IsString()
plateNumber!: string;
@IsEnum(VehicleType)
vehicleType!: VehicleType;
@IsString()
manufacturer!: string;
@IsString()
model!: string;
@IsNumber()
year!: number;
@IsEnum(FuelType)
fuelType!: FuelType;
@IsNumber()
capacity!: number;
@IsEnum(VehicleStatus)
status!: VehicleStatus;
@IsOptional()
@IsString()
description?: string;
}

View File

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

View File

@@ -0,0 +1,64 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
export enum VehicleType {
TRUCK = 'TRUCK',
VAN = 'VAN',
CAR = 'CAR',
BUS = 'BUS',
TRAILER = 'TRAILER',
TANKER = 'TANKER',
FLATBED = 'FLATBED',
}
export enum FuelType {
PETROL = 'PETROL',
DIESEL = 'DIESEL',
ELECTRIC = 'ELECTRIC',
HYBRID = 'HYBRID',
}
export enum VehicleStatus {
ACTIVE = 'ACTIVE',
MAINTENANCE = 'MAINTENANCE',
RETIRED = 'RETIRED',
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
}
@Entity({ name: 'vehicles', schema: 'freight' })
@Index(['plateNumber'])
@Index(['registrationNumber'])
@Index(['status'])
@Index(['vehicleType'])
@Index(['manufacturer'])
export class Vehicle extends BaseEntity {
@Column({ name: 'plate_number', unique: true })
plateNumber!: string;
@Column({ name: 'registration_number', unique: true })
registrationNumber!: string;
@Column({ name: 'vehicle_type', type: 'varchar' })
vehicleType!: VehicleType;
@Column()
manufacturer!: string;
@Column()
model!: string;
@Column()
year!: number;
@Column({ name: 'fuel_type', type: 'varchar' })
fuelType!: FuelType;
@Column()
capacity!: number;
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
status!: VehicleStatus;
@Column({ type: 'text', nullable: true })
description!: string | null;
}

View File

@@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
ParseUUIDPipe,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { VehiclesService } from './vehicles.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@FleetView()
export class VehiclesController {
constructor(private readonly vehiclesService: VehiclesService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Create a new vehicle' })
create(@Body() createVehicleDto: CreateVehicleDto) {
return this.vehiclesService.create(createVehicleDto);
}
@Get()
@ApiOperation({ summary: 'Get all vehicles with filters' })
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.vehiclesService.findAll({
search,
status: status as any,
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get vehicle by id' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.vehiclesService.findById(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a vehicle' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateVehicleDto: UpdateVehicleDto,
) {
return this.vehiclesService.update(id, updateVehicleDto);
}
@Delete(':id')
@FleetManage()
@ApiOperation({ summary: 'Delete a vehicle' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.vehiclesService.remove(id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Vehicle } from './entities/vehicle.entity';
import { VehiclesService } from './vehicles.service';
import { VehiclesController } from './vehicles.controller';
@Module({
imports: [TypeOrmModule.forFeature([Vehicle])],
providers: [VehiclesService],
controllers: [VehiclesController],
exports: [VehiclesService],
})
export class VehiclesModule {}

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Vehicle } from './entities/vehicle.entity';
@Injectable()
export class VehiclesRepository extends BaseRepository<Vehicle> {
constructor(
@InjectRepository(Vehicle)
repository: Repository<Vehicle>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,109 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
@Injectable()
export class VehiclesService {
constructor(
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
) {}
async create(dto: CreateVehicleDto): Promise<Vehicle> {
const existing = await this.vehicleRepo.findOne({
where: { plateNumber: dto.plateNumber },
});
if (existing) {
throw new ConflictException(
`Vehicle with plate number ${dto.plateNumber} already exists`,
);
}
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
const vehicle = this.vehicleRepo.create({
...dto,
registrationNumber,
});
return this.vehicleRepo.save(vehicle);
}
async findAll(query: {
search?: string;
status?: VehicleStatus | string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
} = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
const page = query.page || 1;
const limit = query.limit || 10;
const skip = (page - 1) * limit;
const where: any = {};
if (query.status) where.status = query.status;
let qb = this.vehicleRepo.createQueryBuilder('v');
if (query.search) {
qb = qb.where(
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
{ search: `%${query.search}%` },
);
}
if (query.status) {
qb = qb.andWhere('v.status = :status', { status: query.status });
}
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
query.sortBy ?? '',
)
? query.sortBy
: 'createdAt';
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
const [data, total] = await qb
.orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { data, total, page, limit };
}
async findById(id: string): Promise<Vehicle> {
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
if (!vehicle) {
throw new NotFoundException(`Vehicle ${id} not found`);
}
return vehicle;
}
async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
const vehicle = await this.findById(id);
if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
const existing = await this.vehicleRepo.findOne({
where: { plateNumber: dto.plateNumber },
});
if (existing) {
throw new ConflictException(
`Vehicle with plate number ${dto.plateNumber} already exists`,
);
}
}
Object.assign(vehicle, dto);
return this.vehicleRepo.save(vehicle);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.vehicleRepo.softDelete(id);
}
}

View File

@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Wagon } from './entities/wagon.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
import { WagonsService } from './wagons.service';
@Module({
imports: [TypeOrmModule.forFeature([Wagon, Train])],
imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])],
controllers: [WagonsController, TrainWagonsReorderController],
providers: [WagonsService],
exports: [WagonsService],
})
export class WagonsModule {}
export class WagonsModule {}

View File

@@ -0,0 +1,96 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator';
export class CreateAllocationRuleDto {
@ApiProperty()
@IsString()
name!: string;
@ApiPropertyOptional({ default: 100 })
@IsOptional()
@IsInt()
priority?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
freightType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tradeDirection?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cargoTypeCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerStatus?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
requiresInspection?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
targetFacilityCode?: string;
@ApiProperty()
@IsString()
targetYardCode!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
targetWarehouseCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
targetZoneCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
storageType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateAllocationRuleDto extends PartialType(CreateAllocationRuleDto) {}
export class AllocationPreviewDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
freightType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tradeDirection?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cargoTypeCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerStatus?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
requiresInspection?: boolean;
}

View File

@@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import {
INSPECTION_REPORT_TYPES,
INSPECTION_STATUSES,
InspectionReportType,
InspectionStatus,
} from '../entities/warehouse-inspection-report.entity';
export class CreateInspectionReportDto {
@ApiProperty({ enum: INSPECTION_REPORT_TYPES })
@IsEnum(INSPECTION_REPORT_TYPES)
reportType!: InspectionReportType;
@ApiProperty({ enum: INSPECTION_STATUSES })
@IsEnum(INSPECTION_STATUSES)
inspectionStatus!: InspectionStatus;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasDamage?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
damageDescription?: string;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasWeightLoss?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
expectedWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
actualWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
hasMissingItems?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
missingItemsDescription?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
remarks?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
inspectedById?: string;
}

View File

@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity';
export class CreateWarehouseYardDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
@IsOptional()
@IsUUID()
warehouseId?: string;
@ApiProperty()
@IsString()
@MaxLength(160)
name!: string;
@ApiProperty()
@IsString()
@MaxLength(40)
code!: string;
@ApiProperty({ enum: WAREHOUSE_YARD_TYPES })
@IsEnum(WAREHOUSE_YARD_TYPES)
type!: WarehouseYardType;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
capacityWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
capacityContainers?: number;
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
maxWeight?: number;
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
@IsOptional()
@IsNumber()
@Min(0)
maxVolume?: number;
}

View File

@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_ZONE_TYPES, WarehouseZoneType } from '../entities/warehouse-zone.entity';
export class CreateWarehouseZoneDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiProperty()
@IsString()
@MaxLength(160)
name!: string;
@ApiProperty()
@IsString()
@MaxLength(40)
code!: string;
@ApiProperty({ enum: WAREHOUSE_ZONE_TYPES })
@IsEnum(WAREHOUSE_ZONE_TYPES)
type!: WarehouseZoneType;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
capacityWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
capacityContainers?: number;
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
maxWeight?: number;
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
@IsOptional()
@IsNumber()
@Min(0)
maxVolume?: number;
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
export class CreateWarehouseDto {
@ApiProperty()
@IsString()
@MaxLength(160)
name!: string;
@ApiProperty()
@IsString()
@MaxLength(40)
code!: string;
@ApiProperty({ enum: WAREHOUSE_TYPES })
@IsEnum(WAREHOUSE_TYPES)
type!: WarehouseType;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
stationId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Parent facility / port this warehouse belongs to.' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(200)
locationName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
capacityWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
capacityContainers?: number;
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
@IsOptional()
@IsNumber()
@Min(0)
maxWeight?: number;
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
@IsOptional()
@IsNumber()
@Min(0)
maxVolume?: number;
}

View File

@@ -0,0 +1,76 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
export class CreateFeeRuleDto {
@ApiProperty()
@IsString()
name!: string;
@ApiProperty({ enum: FEE_RULE_TYPES })
@IsEnum(FEE_RULE_TYPES)
ruleType!: FeeRuleType;
@ApiPropertyOptional({ default: 100 })
@IsOptional()
@IsInt()
priority?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
freightType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tradeDirection?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cargoTypeCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerType?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiProperty({ description: 'Grace period in days before charging starts.' })
@IsInt()
@Min(0)
freeDays!: number;
@ApiProperty()
@IsNumber()
@Min(0)
ratePerDay!: number;
@ApiPropertyOptional({ default: 'USD' })
@IsOptional()
@IsString()
currency?: string;
}
export class UpdateFeeRuleDto extends PartialType(CreateFeeRuleDto) {}

View File

@@ -0,0 +1,54 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import {
WAREHOUSE_INVENTORY_STATUSES,
WarehouseInventoryStatus,
} from '../entities/warehouse-inventory.entity';
export class FilterWarehouseInventoryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
bookingId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
containerId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
goodsId?: string;
@ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES })
@IsOptional()
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
status?: WarehouseInventoryStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
}

View File

@@ -0,0 +1,26 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
export class FilterWarehouseDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: WAREHOUSE_TYPES })
@IsOptional()
@IsEnum(WAREHOUSE_TYPES)
type?: WarehouseType;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
stationId?: string;
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES })
@IsOptional()
@IsEnum(WAREHOUSE_STATUSES)
status?: WarehouseStatus;
}

View File

@@ -0,0 +1,49 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import {
WAREHOUSE_INVENTORY_STATUSES,
WarehouseInventoryStatus,
} from '../entities/warehouse-inventory.entity';
export class InquiryWarehouseInventoryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
bookingNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
cargoType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
goodsName?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES })
@IsOptional()
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
status?: WarehouseInventoryStatus;
}

View File

@@ -0,0 +1,31 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class GenerateInvoiceDto {
@ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' })
@IsOptional()
@IsBoolean()
confirmZero?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class PayInvoiceBodyDto {
@ApiPropertyOptional()
@IsNumber()
@Min(0.01)
amount!: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
method?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
reference?: string;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class LoadInventoryDto {
@ApiProperty({ format: 'uuid', description: 'Physical wagon the item is loaded onto' })
@IsUUID()
wagonId!: string;
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' })
@IsOptional()
@IsNumber()
@Min(0)
loadedWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
loadedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class MoveInventoryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
zoneId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
remarks?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
movedBy?: string;
}

View File

@@ -0,0 +1,62 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
export class ReceiveWarehouseInventoryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
zoneId!: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
bookingId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
containerId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
goodsId?: string;
@ApiProperty()
@IsNumber()
@Min(0)
quantity!: number;
@ApiProperty()
@IsNumber()
@Min(0)
weight!: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
volume?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

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