Merge pull request #225 from Tria-plc/freight/develop

Freight/develop
This commit is contained in:
marshal
2026-06-19 16:13:54 +03:00
committed by GitHub
230 changed files with 16671 additions and 1965 deletions

1
.gitignore vendored
View File

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

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";
@@ -46,6 +45,8 @@ import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
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
@@ -54,6 +55,8 @@ 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';
@@ -93,7 +96,6 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
TrainSchedulesModule,
TrainSchedulingModule,
SchedulingRescheduleModule,
CustomersModule,
CompaniesModule,
TrackingModule,
BillingModule,
@@ -112,6 +114,8 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
ContainersModule,
CargoesModule,
RoutesModule,
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,
],
@@ -123,6 +127,8 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {

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

@@ -689,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')
@@ -706,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,
});
@@ -765,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

@@ -11,6 +11,7 @@ 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';
@@ -273,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 } });
@@ -290,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());

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

@@ -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)) };
}
@@ -1989,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

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

View File

@@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class ReserveInventoryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
inventoryId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -0,0 +1,34 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString, IsUUID } from 'class-validator';
export class UnloadBookingDto {
@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;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
unloadedAt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateInspectionReportDto } from './create-inspection-report.dto';
export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {}

View File

@@ -0,0 +1,12 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { WAREHOUSE_YARD_STATUSES, WarehouseYardStatus } from '../entities/warehouse-yard.entity';
import { CreateWarehouseYardDto } from './create-warehouse-yard.dto';
export class UpdateWarehouseYardDto extends PartialType(CreateWarehouseYardDto) {
@ApiPropertyOptional({ enum: WAREHOUSE_YARD_STATUSES })
@IsOptional()
@IsEnum(WAREHOUSE_YARD_STATUSES)
status?: WarehouseYardStatus;
}

View File

@@ -0,0 +1,12 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { WAREHOUSE_ZONE_STATUSES, WarehouseZoneStatus } from '../entities/warehouse-zone.entity';
import { CreateWarehouseZoneDto } from './create-warehouse-zone.dto';
export class UpdateWarehouseZoneDto extends PartialType(CreateWarehouseZoneDto) {
@ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STATUSES })
@IsOptional()
@IsEnum(WAREHOUSE_ZONE_STATUSES)
status?: WarehouseZoneStatus;
}

View File

@@ -0,0 +1,12 @@
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { WAREHOUSE_STATUSES, WarehouseStatus } from '../entities/warehouse.entity';
import { CreateWarehouseDto } from './create-warehouse.dto';
export class UpdateWarehouseDto extends PartialType(CreateWarehouseDto) {
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES })
@IsOptional()
@IsEnum(WAREHOUSE_STATUSES)
status?: WarehouseStatus;
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAREHOUSE_ACTIVITY_TYPES = [
'INVENTORY_RECEIVED',
'INVENTORY_STORED',
'INVENTORY_MOVED',
'INVENTORY_RESERVED',
'READY_FOR_LOADING',
'INVENTORY_LOADED',
'INVENTORY_DISPATCHED',
] as const;
export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number];
@Entity({ schema: 'freight', name: 'warehouse_activity_log' })
@Index(['inventoryId'])
@Index(['warehouseId'])
@Index(['activityType'])
export class WarehouseActivityLog extends BaseEntity {
@Column({ name: 'inventory_id', type: 'uuid', nullable: true })
inventoryId?: string | null;
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId?: string | null;
@Column({ name: 'activity_type', type: 'varchar', length: 40 })
activityType!: WarehouseActivityType;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
}

View File

@@ -0,0 +1,54 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/**
* Batch 5 — deterministic warehouse/yard allocation.
* A booking's (freightType, tradeDirection, cargoType, containerStatus, inspection)
* is matched against active rules in ascending `priority`; the first match wins and
* resolves the target Yard (and optional Warehouse/Zone) by code.
*/
@Entity({ schema: 'freight', name: 'warehouse_allocation_rules' })
@Index(['priority'])
@Index(['isActive'])
export class WarehouseAllocationRule extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 160 })
name!: string;
@Column({ name: 'priority', type: 'int', default: 100 })
priority!: number;
// ── Match criteria (null = wildcard) ──────────────────────────────────────
@Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true })
freightType?: string | null; // CONTAINER | BULK
@Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true })
tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH
@Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true })
cargoTypeCode?: string | null;
@Column({ name: 'container_status', type: 'varchar', length: 24, nullable: true })
containerStatus?: string | null; // e.g. EMPTY | MAINTENANCE
@Column({ name: 'requires_inspection', type: 'boolean', nullable: true })
requiresInspection?: boolean | null;
// ── Resolved target (by code) ─────────────────────────────────────────────
@Column({ name: 'target_facility_code', type: 'varchar', length: 40, nullable: true })
targetFacilityCode?: string | null;
@Column({ name: 'target_yard_code', type: 'varchar', length: 40 })
targetYardCode!: string;
@Column({ name: 'target_warehouse_code', type: 'varchar', length: 40, nullable: true })
targetWarehouseCode?: string | null;
@Column({ name: 'target_zone_code', type: 'varchar', length: 40, nullable: true })
targetZoneCode?: string | null;
@Column({ name: 'storage_type', type: 'varchar', length: 80, nullable: true })
storageType?: string | null; // descriptive: "Container terminal import / stack area"
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,50 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity';
export const WAREHOUSE_FEE_TYPES = [
'CONTAINER_DEMURRAGE',
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' })
@Index(['invoiceId'])
export class WarehouseFeeInvoiceItem extends BaseEntity {
@Column({ name: 'invoice_id', type: 'uuid' })
invoiceId!: string;
@ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoice_id' })
invoice?: WarehouseFeeInvoice;
@Column({ name: 'fee_rule_id', type: 'uuid', nullable: true })
feeRuleId?: string | null;
@Column({ name: 'fee_type', type: 'varchar', length: 32 })
feeType!: WarehouseFeeType;
@Column({ name: 'description', type: 'varchar', length: 255 })
description!: string;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 })
quantity!: number;
@Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 })
unitRate!: number;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
amount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
@Column({ name: 'chargeable_days', type: 'int', nullable: true })
chargeableDays?: number | null;
@Column({ name: 'free_days', type: 'int', nullable: true })
freeDays?: number | null;
}

View File

@@ -0,0 +1,107 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
/** A single recorded payment against a warehouse fee invoice (history). */
export interface WarehouseInvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
/**
* Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation.
* Owns warehouse fees; links to booking/customer/inventory/location so it can
* connect to the existing payment module without duplicating it.
*/
@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' })
@Index(['invoiceNumber'], { unique: true })
@Index(['bookingId'])
@Index(['inventoryId'])
@Index(['status'])
export class WarehouseFeeInvoice extends BaseEntity {
@Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true })
invoiceNumber!: string;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId?: string | null;
@Column({ name: 'inventory_id', type: 'uuid' })
inventoryId!: string;
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
facilityId?: string | null;
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId?: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId?: string | null;
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
zoneId?: string | null;
@Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' })
invoiceType!: WarehouseInvoiceType;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: WarehouseInvoiceStatus;
@Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;
@Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
taxAmount!: number;
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
@Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
paidAmount!: number;
@Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
balanceAmount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
/** Charge window covered by this invoice — used to allow a later invoice for a new period. */
@Column({ name: 'period_start', type: 'timestamptz', nullable: true })
periodStart?: Date | null;
@Column({ name: 'period_end', type: 'timestamptz', nullable: true })
periodEnd?: Date | null;
@Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
issuedAt?: Date | null;
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
dueDate?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date | null;
@Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" })
payments!: WarehouseInvoicePayment[];
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,62 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
/**
* Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6).
* The most specific active rule (highest `specificity` then lowest `priority`) applies to an item.
* `freeDays` is the grace period; charging starts the day after it expires.
*/
@Entity({ schema: 'freight', name: 'warehouse_fee_rules' })
@Index(['ruleType'])
@Index(['isActive'])
export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 160 })
name!: string;
@Column({ name: 'rule_type', type: 'varchar', length: 20 })
ruleType!: FeeRuleType;
@Column({ name: 'priority', type: 'int', default: 100 })
priority!: number;
// ── Scope (null = applies to all) ─────────────────────────────────────────
@Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true })
freightType?: string | null; // CONTAINER | BULK
@Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true })
tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH
@Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true })
cargoTypeCode?: string | null;
@Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true })
containerType?: string | null;
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
facilityId?: string | null;
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId?: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId?: string | null;
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
zoneId?: string | null;
// ── Fee definition ────────────────────────────────────────────────────────
@Column({ name: 'free_days', type: 'int', default: 0 })
freeDays!: number;
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
ratePerDay!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,77 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { WarehouseInventory } from './warehouse-inventory.entity';
export const INSPECTION_REPORT_TYPES = [
'INSPECTION',
'DAMAGE',
'WEIGHT_LOSS',
'MISSING_ITEM',
'GENERAL',
] as const;
export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number];
export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const;
export type InspectionStatus = (typeof INSPECTION_STATUSES)[number];
@Entity({ schema: 'freight', name: 'warehouse_inspection_reports' })
@Index(['inventoryId'])
@Index(['bookingId'])
@Index(['inspectionStatus'])
export class WarehouseInspectionReport extends BaseEntity {
@Column({ name: 'inventory_id', type: 'uuid' })
inventoryId!: string;
@ManyToOne(() => WarehouseInventory)
@JoinColumn({ name: 'inventory_id' })
inventory?: WarehouseInventory;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId?: string | null;
@Column({ name: 'report_type', type: 'varchar', length: 32, default: 'INSPECTION' })
reportType!: InspectionReportType;
@Column({ name: 'inspection_status', type: 'varchar', length: 20, default: 'NEEDS_REVIEW' })
inspectionStatus!: InspectionStatus;
@Column({ name: 'has_damage', type: 'boolean', default: false })
hasDamage!: boolean;
@Column({ name: 'damage_description', type: 'text', nullable: true })
damageDescription?: string | null;
@Column({ name: 'has_weight_loss', type: 'boolean', default: false })
hasWeightLoss!: boolean;
@Column({ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
expectedWeight?: number | null;
@Column({ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
actualWeight?: number | null;
@Column({ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, nullable: true })
weightLoss?: number | null;
@Column({ name: 'weight_loss_unit', type: 'varchar', length: 12, nullable: true })
weightLossUnit?: string | null;
@Column({ name: 'has_missing_items', type: 'boolean', default: false })
hasMissingItems!: boolean;
@Column({ name: 'missing_items_description', type: 'text', nullable: true })
missingItemsDescription?: string | null;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
@Column({ name: 'inspected_by_id', type: 'uuid', nullable: true })
inspectedById?: string | null;
@Column({ name: 'inspected_at', type: 'timestamptz', nullable: true })
inspectedAt?: Date | null;
}

View File

@@ -0,0 +1,42 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { WarehouseInventory } from './warehouse-inventory.entity';
@Entity({ schema: 'freight', name: 'warehouse_inventory_movement' })
@Index(['inventoryId'])
export class WarehouseInventoryMovement extends BaseEntity {
@Column({ name: 'inventory_id', type: 'uuid' })
inventoryId!: string;
@ManyToOne(() => WarehouseInventory, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'inventory_id' })
inventory?: WarehouseInventory;
@Column({ name: 'from_warehouse_id', type: 'uuid' })
fromWarehouseId!: string;
@Column({ name: 'from_yard_id', type: 'uuid' })
fromYardId!: string;
@Column({ name: 'from_zone_id', type: 'uuid' })
fromZoneId!: string;
@Column({ name: 'to_warehouse_id', type: 'uuid' })
toWarehouseId!: string;
@Column({ name: 'to_yard_id', type: 'uuid' })
toYardId!: string;
@Column({ name: 'to_zone_id', type: 'uuid' })
toZoneId!: string;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
@Column({ name: 'moved_by', type: 'varchar', length: 120, nullable: true })
movedBy?: string | null;
@Column({ name: 'moved_at', type: 'timestamptz' })
movedAt!: Date;
}

View File

@@ -0,0 +1,143 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } 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 { Warehouse } from './warehouse.entity';
import { WarehouseYard } from './warehouse-yard.entity';
import { WarehouseZone } from './warehouse-zone.entity';
// Batch 2 lifecycle. Supersedes the Batch 1 set
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
export const WAREHOUSE_INVENTORY_STATUSES = [
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
] as const;
export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number];
/** Allowed forward transitions for the inventory lifecycle. */
export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, WarehouseInventoryStatus[]> = {
RECEIVED: ['STORED'],
STORED: ['RESERVED'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],
DISPATCHED: [],
};
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
@Index(['warehouseId'])
@Index(['yardId'])
@Index(['zoneId'])
@Index(['bookingId'])
@Index(['cargoId'])
@Index(['containerId'])
@Index(['goodsId'])
@Index(['status'])
export class WarehouseInventory extends BaseEntity {
@Column({ name: 'warehouse_id', type: 'uuid' })
warehouseId!: string;
@ManyToOne(() => Warehouse)
@JoinColumn({ name: 'warehouse_id' })
warehouse?: Warehouse;
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => WarehouseYard)
@JoinColumn({ name: 'yard_id' })
yard?: WarehouseYard;
@Column({ name: 'zone_id', type: 'uuid' })
zoneId!: string;
@ManyToOne(() => WarehouseZone)
@JoinColumn({ name: 'zone_id' })
zone?: WarehouseZone;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
cargoId?: string | null;
@ManyToOne(() => Cargo, { nullable: true })
@JoinColumn({ name: 'cargo_id' })
cargo?: Cargo | null;
@Column({ name: 'container_id', type: 'uuid', nullable: true })
containerId?: string | null;
@ManyToOne(() => Container, { nullable: true })
@JoinColumn({ name: 'container_id' })
container?: Container | null;
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
goodsId?: string | null;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
quantity!: number;
@Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
weight!: number;
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
volume?: number | null;
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
status!: WarehouseInventoryStatus;
// Batch 4.5: latest inspection outcome (PASSED | FAILED | NEEDS_REVIEW). Null = not yet inspected.
@Column({ name: 'inspection_status', type: 'varchar', length: 20, nullable: true })
inspectionStatus?: string | null;
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
storedAt?: Date | null;
@Column({ name: 'reserved_at', type: 'timestamptz', nullable: true })
reservedAt?: Date | null;
@Column({ name: 'inspected_at', type: 'timestamptz', nullable: true })
inspectedAt?: Date | null;
@Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true })
readyForLoadingAt?: Date | null;
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
@Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true })
dispatchedAt?: Date | null;
// Batch 5 — demurrage / storage lifecycle timestamps.
@Column({ name: 'inspection_started_at', type: 'timestamptz', nullable: true })
inspectionStartedAt?: Date | null;
@Column({ name: 'inspection_completed_at', type: 'timestamptz', nullable: true })
inspectionCompletedAt?: Date | null;
@Column({ name: 'ready_for_pickup_at', type: 'timestamptz', nullable: true })
readyForPickupAt?: Date | null;
@Column({ name: 'release_date', type: 'timestamptz', nullable: true })
releaseDate?: Date | null;
@Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true })
gateClearedAt?: Date | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { WarehouseInventory } from './warehouse-inventory.entity';
/**
* Batch 3 — a record that a warehouse inventory item was physically loaded onto a wagon.
* The warehouse OWNS this record. It only READS wagon/schedule data from the scheduling
* domain (via SchedulingReadFacade); it never writes to wagons or train schedules.
*/
@Entity({ schema: 'freight', name: 'warehouse_loadings' })
@Index(['warehouseInventoryId'])
@Index(['bookingId'])
@Index(['wagonId'])
export class WarehouseLoading extends BaseEntity {
@Column({ name: 'warehouse_inventory_id', type: 'uuid' })
warehouseInventoryId!: string;
@ManyToOne(() => WarehouseInventory)
@JoinColumn({ name: 'warehouse_inventory_id' })
inventory?: WarehouseInventory;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
/** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */
@Column({ name: 'wagon_id', type: 'uuid' })
wagonId!: string;
@Column({ name: 'loaded_at', type: 'timestamptz' })
loadedAt!: Date;
@Column({ name: 'loaded_by', type: 'varchar', length: 120, nullable: true })
loadedBy?: string | null;
@Column({ name: 'loaded_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
loadedWeight?: number | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,69 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Warehouse } from './warehouse.entity';
import { WarehouseZone } from './warehouse-zone.entity';
export const WAREHOUSE_YARD_TYPES = [
'CONTAINER_YARD',
'BULK_YARD',
'GENERAL_CARGO_YARD',
'HAZARDOUS_YARD',
'COLD_STORAGE_YARD',
] as const;
export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number];
@Entity({ schema: 'freight', name: 'warehouse_yards' })
@Index(['warehouseId'])
@Index(['type'])
@Index(['status'])
export class WarehouseYard extends BaseEntity {
@Column({ name: 'warehouse_id', type: 'uuid' })
warehouseId!: string;
@ManyToOne(() => Warehouse, (warehouse) => warehouse.yards, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'warehouse_id' })
warehouse?: Warehouse;
@Column({ name: 'name', type: 'varchar', length: 160 })
name!: string;
@Column({ name: 'code', type: 'varchar', length: 40 })
code!: string;
@Column({ name: 'type', type: 'varchar', length: 32 })
type!: WarehouseYardType;
@Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
capacityWeight?: number | null;
@Column({ name: 'capacity_containers', type: 'int', nullable: true })
capacityContainers?: number | null;
@Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
currentWeight!: number;
@Column({ name: 'current_containers', type: 'int', default: 0 })
currentContainers!: number;
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
maxWeight?: number | null;
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
maxVolume?: number | null;
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
currentVolume!: number;
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
status!: WarehouseYardStatus;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => WarehouseZone, (zone) => zone.yard)
zones?: WarehouseZone[];
}

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