mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ node_modules/
|
|||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
**/*.tsbuildinfo
|
**/*.tsbuildinfo
|
||||||
|
**/vite.config.ts.timestamp-*.mjs
|
||||||
|
|
||||||
# env
|
# env
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"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",
|
"type-check": "tsc --noEmit",
|
||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"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",
|
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
|||||||
import { PaymentModule } from "./modules/payment/payment.module";
|
import { PaymentModule } from "./modules/payment/payment.module";
|
||||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.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 { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
@@ -54,6 +56,8 @@ import { WagonsModule } from './modules/wagons/wagons.module';
|
|||||||
import { ContainersModule } from './modules/container-management/containers.module';
|
import { ContainersModule } from './modules/container-management/containers.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
import { RoutesModule } from './modules/routes/routes.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 { OverviewModule } from './modules/overview/overview.module';
|
||||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||||
|
|
||||||
@@ -112,6 +116,8 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
|||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
RoutesModule,
|
RoutesModule,
|
||||||
|
FacilitiesModule,
|
||||||
|
WarehousesModule,
|
||||||
OverviewModule,
|
OverviewModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
],
|
],
|
||||||
@@ -123,6 +129,8 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
|||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
DemoFreightDataSeeder,
|
DemoFreightDataSeeder,
|
||||||
|
IndodeFacilitySeeder,
|
||||||
|
Batch14TestDataSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
|||||||
],
|
],
|
||||||
migrationsRun: true,
|
migrationsRun: true,
|
||||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||||
synchronize: true,
|
synchronize: false,
|
||||||
logging: process.env.NODE_ENV === "development",
|
logging: process.env.NODE_ENV === "development",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DataSource } from 'typeorm';
|
|||||||
export const AppDataSource = new DataSource({
|
export const AppDataSource = new DataSource({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: process.env.DB_HOST ?? 'localhost',
|
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',
|
username: process.env.DB_USER ?? 'postgres',
|
||||||
password: process.env.DB_PASSWORD ?? '',
|
password: process.env.DB_PASSWORD ?? '',
|
||||||
database: process.env.DB_NAME ?? 'edr_freight',
|
database: process.env.DB_NAME ?? 'edr_freight',
|
||||||
@@ -14,7 +14,7 @@ export const AppDataSource = new DataSource({
|
|||||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: true,
|
logging: process.env.TYPEORM_LOGGING === 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optional: call ensurePostgresSchemas before initializing
|
// Optional: call ensurePostgresSchemas before initializing
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ export const BOOKING_STATUSES = [
|
|||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
'APPROVED_PENDING_SIGNATURE',
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
'APPROVED',
|
'APPROVED',
|
||||||
|
'READY_FOR_ASSIGNMENT',
|
||||||
|
'WAGON_ASSIGNED',
|
||||||
|
'INVOICED',
|
||||||
'CONTRACT_READY',
|
'CONTRACT_READY',
|
||||||
'SIGNED_CUSTOMER',
|
'SIGNED_CUSTOMER',
|
||||||
'FULLY_EXECUTED',
|
'FULLY_EXECUTED',
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ export class CargoesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cargo.status = 'DELIVERED';
|
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 =
|
const remaining =
|
||||||
cargo.containerId != null
|
cargo.containerId != null
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class DeliverCargoDto {
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
deliveryRemarks?: string;
|
deliveryRemarks?: string;
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ export class Cargo extends BaseEntity {
|
|||||||
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
|
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
|
||||||
unloadedAt!: Date | null;
|
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 })
|
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
|
||||||
wagonBookingAllocationId!: string | null;
|
wagonBookingAllocationId!: string | null;
|
||||||
|
|
||||||
@@ -57,6 +67,7 @@ export class Cargo extends BaseEntity {
|
|||||||
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
|
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
|
||||||
loadType!: string | null;
|
loadType!: string | null;
|
||||||
|
|
||||||
|
// Relationship to Container
|
||||||
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
|
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
|
||||||
@JoinColumn({ name: 'container_id' })
|
@JoinColumn({ name: 'container_id' })
|
||||||
container!: Container | null;
|
container!: Container | null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
|||||||
|
|
||||||
export const LOCOMOTIVE_STATUSES = [
|
export const LOCOMOTIVE_STATUSES = [
|
||||||
'AVAILABLE',
|
'AVAILABLE',
|
||||||
|
'UNAVAILABLE',
|
||||||
|
'IMPORT_READY',
|
||||||
|
'EXPORT_READY',
|
||||||
'ASSIGNED',
|
'ASSIGNED',
|
||||||
'MAINTENANCE',
|
'MAINTENANCE',
|
||||||
'OUT_OF_SERVICE',
|
'OUT_OF_SERVICE',
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export class TrainSetWagon extends BaseEntity {
|
|||||||
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
||||||
wagonTypeId!: string;
|
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)
|
@ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons)
|
||||||
@JoinColumn({ name: 'wagon_type_id' })
|
@JoinColumn({ name: 'wagon_type_id' })
|
||||||
wagonType?: WagonType;
|
wagonType?: WagonType;
|
||||||
@@ -45,13 +52,6 @@ export class TrainSetWagon extends BaseEntity {
|
|||||||
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
|
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
|
||||||
assignedWeightTons!: number;
|
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' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
|
||||||
status!: string;
|
status!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Wagon } from './entities/wagon.entity';
|
import { Wagon } from './entities/wagon.entity';
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Train } from '../trains/entities/train.entity';
|
||||||
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
|
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
|
||||||
import { WagonsService } from './wagons.service';
|
import { WagonsService } from './wagons.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Wagon, Train])],
|
imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])],
|
||||||
controllers: [WagonsController, TrainWagonsReorderController],
|
controllers: [WagonsController, TrainWagonsReorderController],
|
||||||
providers: [WagonsService],
|
providers: [WagonsService],
|
||||||
exports: [WagonsService],
|
exports: [WagonsService],
|
||||||
})
|
})
|
||||||
export class WagonsModule {}
|
export class WagonsModule {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateInspectionReportDto } from './create-inspection-report.dto';
|
||||||
|
|
||||||
|
export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { WarehouseYard } from './warehouse-yard.entity';
|
||||||
|
|
||||||
|
export const WAREHOUSE_ZONE_TYPES = [
|
||||||
|
'CONTAINER_ZONE',
|
||||||
|
'BULK_ZONE',
|
||||||
|
'GENERAL_CARGO_ZONE',
|
||||||
|
'HAZARDOUS_ZONE',
|
||||||
|
'COLD_STORAGE_ZONE',
|
||||||
|
] as const;
|
||||||
|
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
|
||||||
|
|
||||||
|
export const WAREHOUSE_ZONE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||||
|
export type WarehouseZoneStatus = (typeof WAREHOUSE_ZONE_STATUSES)[number];
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'warehouse_zones' })
|
||||||
|
@Index(['yardId'])
|
||||||
|
@Index(['type'])
|
||||||
|
@Index(['status'])
|
||||||
|
export class WarehouseZone extends BaseEntity {
|
||||||
|
@Column({ name: 'yard_id', type: 'uuid' })
|
||||||
|
yardId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => WarehouseYard, (yard) => yard.zones, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'yard_id' })
|
||||||
|
yard?: WarehouseYard;
|
||||||
|
|
||||||
|
@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!: WarehouseZoneType;
|
||||||
|
|
||||||
|
@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!: WarehouseZoneStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
|
import { Facility } from '../../facilities/entities/facility.entity';
|
||||||
|
import { WarehouseYard } from './warehouse-yard.entity';
|
||||||
|
|
||||||
|
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
|
||||||
|
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
|
||||||
|
|
||||||
|
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||||
|
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'warehouses' })
|
||||||
|
@Index(['code'], { unique: true })
|
||||||
|
@Index(['type'])
|
||||||
|
@Index(['status'])
|
||||||
|
@Index(['stationId'])
|
||||||
|
export class Warehouse extends BaseEntity {
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||||
|
type!: WarehouseType;
|
||||||
|
|
||||||
|
@Column({ name: 'station_id', type: 'uuid', nullable: true })
|
||||||
|
stationId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
locationName?: string | null;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
// Batch 2 capacity (weight + volume). maxWeight backfilled from capacityWeight.
|
||||||
|
@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!: WarehouseStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
|
||||||
|
facilityId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true })
|
||||||
|
facility?: Facility | null;
|
||||||
|
|
||||||
|
@OneToMany(() => WarehouseYard, (yard) => yard.warehouse)
|
||||||
|
yards?: WarehouseYard[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READ-ONLY view into the train-scheduling / wagons domain for the warehouse module.
|
||||||
|
*
|
||||||
|
* IMPORTANT: this facade only ever runs SELECTs. The warehouse must never modify
|
||||||
|
* wagon assignment, rescheduling, import_ready/export_ready, or locomotive flow.
|
||||||
|
* It is intentionally decoupled (raw SQL) so it does not import the scheduling
|
||||||
|
* services/entities and cannot accidentally write to them.
|
||||||
|
*/
|
||||||
|
export interface WagonView {
|
||||||
|
id: string;
|
||||||
|
wagonNumber: string;
|
||||||
|
status: string;
|
||||||
|
trainId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingScheduleView {
|
||||||
|
schedule: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
scheduledDepartureDate: string | null;
|
||||||
|
scheduledArrivalDate: string | null;
|
||||||
|
originStationId: string | null;
|
||||||
|
destinationStationId: string | null;
|
||||||
|
} | null;
|
||||||
|
wagon: {
|
||||||
|
wagonId: string | null;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
sequenceNo: number | null;
|
||||||
|
allocatedWeightTons: number | null;
|
||||||
|
} | null;
|
||||||
|
/** Mirror of schedule.status — the headline "where is the train" indicator. */
|
||||||
|
departureStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SchedulingReadFacade {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/** Look up a single physical wagon. Returns null if it does not exist. */
|
||||||
|
async findWagon(wagonId: string): Promise<WagonView | null> {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||||
|
FROM freight.wagons
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[wagonId],
|
||||||
|
);
|
||||||
|
return rows?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the wagon is already part of a train set (selected by an existing schedule). */
|
||||||
|
async isWagonScheduled(wagonId: string): Promise<boolean> {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT 1 FROM freight.train_set_wagons
|
||||||
|
WHERE physical_wagon_id = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[wagonId],
|
||||||
|
);
|
||||||
|
return (rows?.length ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List wagons usable for loading (available, or already assigned to a schedule). */
|
||||||
|
listLoadableWagons(): Promise<WagonView[]> {
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||||
|
FROM freight.wagons
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND status NOT IN ('RETIRED', 'MAINTENANCE')
|
||||||
|
ORDER BY wagon_number ASC`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a booking, return its related schedule, wagon assignment and departure status.
|
||||||
|
* All fields are read straight from the scheduling tables — nothing is written.
|
||||||
|
*/
|
||||||
|
async getBookingSchedule(bookingId: string): Promise<BookingScheduleView> {
|
||||||
|
const scheduleRows = await this.dataSource.query(
|
||||||
|
`SELECT ts.id,
|
||||||
|
ts.status,
|
||||||
|
ts.scheduled_departure_date AS "scheduledDepartureDate",
|
||||||
|
ts.scheduled_arrival_date AS "scheduledArrivalDate",
|
||||||
|
ts.origin_station_id AS "originStationId",
|
||||||
|
ts.destination_station_id AS "destinationStationId"
|
||||||
|
FROM freight.train_schedule_bookings tsb
|
||||||
|
INNER JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||||
|
WHERE tsb.booking_id = $1 AND ts.deleted_at IS NULL
|
||||||
|
ORDER BY ts.scheduled_departure_date DESC NULLS LAST
|
||||||
|
LIMIT 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const schedule = scheduleRows?.[0] ?? null;
|
||||||
|
|
||||||
|
const wagonRows = await this.dataSource.query(
|
||||||
|
`SELECT w.id AS "wagonId",
|
||||||
|
w.wagon_number AS "wagonNumber",
|
||||||
|
tsw.sequence_no AS "sequenceNo",
|
||||||
|
wba.allocated_weight_tons AS "allocatedWeightTons"
|
||||||
|
FROM freight.wagon_booking_allocations wba
|
||||||
|
INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||||
|
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||||
|
WHERE wba.booking_id = $1
|
||||||
|
ORDER BY tsw.sequence_no ASC NULLS LAST
|
||||||
|
LIMIT 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const wagon = wagonRows?.[0] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedule,
|
||||||
|
wagon,
|
||||||
|
departureStatus: schedule?.status ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseActivityLogRepository extends BaseRepository<WarehouseActivityLog> {
|
||||||
|
constructor(@InjectRepository(WarehouseActivityLog) repository: Repository<WarehouseActivityLog>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WarehouseActivityLog,
|
||||||
|
WarehouseActivityType,
|
||||||
|
} from './entities/warehouse-activity-log.entity';
|
||||||
|
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||||
|
|
||||||
|
interface LogInput {
|
||||||
|
activityType: WarehouseActivityType;
|
||||||
|
description?: string;
|
||||||
|
inventoryId?: string | null;
|
||||||
|
warehouseId?: string | null;
|
||||||
|
performedBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseActivityLogService {
|
||||||
|
constructor(private readonly logRepository: WarehouseActivityLogRepository) {}
|
||||||
|
|
||||||
|
/** Persist an activity record. Pass a transaction manager to enrol in the caller's transaction. */
|
||||||
|
async record(input: LogInput, manager?: EntityManager): Promise<void> {
|
||||||
|
const data = {
|
||||||
|
activityType: input.activityType,
|
||||||
|
description: input.description ?? null,
|
||||||
|
inventoryId: input.inventoryId ?? null,
|
||||||
|
warehouseId: input.warehouseId ?? null,
|
||||||
|
performedBy: input.performedBy ?? 'system',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (manager) {
|
||||||
|
await manager.getRepository(WarehouseActivityLog).save(
|
||||||
|
manager.getRepository(WarehouseActivityLog).create(data),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.logRepository.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByInventory(inventoryId: string): Promise<WarehouseActivityLog[]> {
|
||||||
|
return this.logRepository.findAll({
|
||||||
|
where: { inventoryId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseAllocationRuleRepository extends BaseRepository<WarehouseAllocationRule> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WarehouseAllocationRule) repository: Repository<WarehouseAllocationRule>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CreateAllocationRuleDto, UpdateAllocationRuleDto } from './dto/allocation-rule.dto';
|
||||||
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
|
import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository';
|
||||||
|
|
||||||
|
export interface AllocationCriteria {
|
||||||
|
freightType?: string | null; // CONTAINER | BULK
|
||||||
|
tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH
|
||||||
|
cargoTypeCode?: string | null;
|
||||||
|
containerStatus?: string | null; // EMPTY | MAINTENANCE | ...
|
||||||
|
requiresInspection?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllocationResult {
|
||||||
|
warehouseId: string;
|
||||||
|
yardId: string;
|
||||||
|
zoneId: string;
|
||||||
|
facilityId: string | null;
|
||||||
|
rule: { id: string; name: string; storageType: string | null } | null;
|
||||||
|
/** Human-readable path: Facility → Warehouse → Yard → Zone. */
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch 5 — deterministic warehouse/yard allocation driven by configurable rules.
|
||||||
|
* Never assigns randomly: matches criteria against active rules by priority and
|
||||||
|
* resolves the target Yard/Warehouse/Zone by code.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseAllocationService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly ruleRepository: WarehouseAllocationRuleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||||
|
listRules(): Promise<WarehouseAllocationRule[]> {
|
||||||
|
return this.ruleRepository.findAll({ order: { priority: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createRule(dto: CreateAllocationRuleDto): Promise<WarehouseAllocationRule> {
|
||||||
|
return this.ruleRepository.create({ isActive: true, priority: 100, ...dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRule(id: string, dto: UpdateAllocationRuleDto): Promise<WarehouseAllocationRule> {
|
||||||
|
const updated = await this.ruleRepository.update(id, dto);
|
||||||
|
if (!updated) throw new NotFoundException(`Allocation rule ${id} not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRule(id: string): Promise<void> {
|
||||||
|
return this.ruleRepository.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(rule: WarehouseAllocationRule, c: AllocationCriteria): boolean {
|
||||||
|
const eq = (ruleVal?: string | null, inVal?: string | null) =>
|
||||||
|
ruleVal == null || (inVal != null && ruleVal.toUpperCase() === inVal.toUpperCase());
|
||||||
|
return (
|
||||||
|
eq(rule.freightType, c.freightType) &&
|
||||||
|
eq(rule.tradeDirection, c.tradeDirection) &&
|
||||||
|
eq(rule.cargoTypeCode, c.cargoTypeCode) &&
|
||||||
|
eq(rule.containerStatus, c.containerStatus) &&
|
||||||
|
(rule.requiresInspection == null || rule.requiresInspection === Boolean(c.requiresInspection))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First active rule (by priority) whose criteria match. */
|
||||||
|
async findMatchingRule(criteria: AllocationCriteria): Promise<WarehouseAllocationRule | null> {
|
||||||
|
const rules = await this.ruleRepository.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { priority: 'ASC' },
|
||||||
|
});
|
||||||
|
return rules.find((r) => this.matches(r, criteria)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
||||||
|
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
||||||
|
const rule = await this.findMatchingRule(criteria);
|
||||||
|
const yardCode = rule?.targetYardCode;
|
||||||
|
|
||||||
|
// Resolve yard (by rule code, else first available yard with a zone).
|
||||||
|
const [yard] = await this.dataSource.query(
|
||||||
|
yardCode
|
||||||
|
? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||||
|
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`
|
||||||
|
: `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||||
|
JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL
|
||||||
|
WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`,
|
||||||
|
yardCode ? [yardCode] : [],
|
||||||
|
);
|
||||||
|
if (!yard) return null;
|
||||||
|
|
||||||
|
// Zone: rule code if given, else first zone in the yard.
|
||||||
|
const [zone] = await this.dataSource.query(
|
||||||
|
rule?.targetZoneCode
|
||||||
|
? `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.code = $1 AND z.deleted_at IS NULL LIMIT 1`
|
||||||
|
: `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.yard_id = $1 AND z.deleted_at IS NULL ORDER BY z.created_at ASC LIMIT 1`,
|
||||||
|
rule?.targetZoneCode ? [rule.targetZoneCode] : [yard.id],
|
||||||
|
);
|
||||||
|
if (!zone) return null;
|
||||||
|
|
||||||
|
const [wh] = await this.dataSource.query(
|
||||||
|
`SELECT w.id, w.name, w.facility_id AS "facilityId",
|
||||||
|
(SELECT name FROM freight.facilities f WHERE f.id = w.facility_id) AS "facilityName"
|
||||||
|
FROM freight.warehouses w WHERE w.id = $1 AND w.deleted_at IS NULL LIMIT 1`,
|
||||||
|
[yard.warehouseId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
warehouseId: yard.warehouseId,
|
||||||
|
yardId: yard.id,
|
||||||
|
zoneId: zone.id,
|
||||||
|
facilityId: wh?.facilityId ?? null,
|
||||||
|
rule: rule ? { id: rule.id, name: rule.name, storageType: rule.storageType ?? null } : null,
|
||||||
|
path: [wh?.facilityName, wh?.name, yard.name, zone.name].filter(Boolean).join(' → '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { Warehouse } from './entities/warehouse.entity';
|
||||||
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
|
|
||||||
|
export interface WarehouseDashboard {
|
||||||
|
totalWarehouses: number;
|
||||||
|
totalInventory: number;
|
||||||
|
receivedToday: number;
|
||||||
|
stored: number;
|
||||||
|
reserved: number;
|
||||||
|
readyForLoading: number;
|
||||||
|
loaded: number;
|
||||||
|
dispatched: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseDashboardService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async getDashboard(): Promise<WarehouseDashboard> {
|
||||||
|
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||||
|
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||||
|
|
||||||
|
const startOfToday = new Date();
|
||||||
|
startOfToday.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] =
|
||||||
|
await Promise.all([
|
||||||
|
warehouseRepo.count(),
|
||||||
|
inventoryRepo.count(),
|
||||||
|
inventoryRepo.count({ where: { status: 'STORED' } }),
|
||||||
|
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
||||||
|
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
||||||
|
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
||||||
|
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
||||||
|
inventoryRepo
|
||||||
|
.createQueryBuilder('inv')
|
||||||
|
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||||
|
.getCount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalWarehouses,
|
||||||
|
totalInventory,
|
||||||
|
receivedToday,
|
||||||
|
stored,
|
||||||
|
reserved,
|
||||||
|
readyForLoading,
|
||||||
|
loaded,
|
||||||
|
dispatched,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseFeeInvoiceItemRepository extends BaseRepository<WarehouseFeeInvoiceItem> {
|
||||||
|
constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository<WarehouseFeeInvoiceItem>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseFeeInvoiceRepository extends BaseRepository<WarehouseFeeInvoice> {
|
||||||
|
constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository<WarehouseFeeInvoice>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseFeeRuleRepository extends BaseRepository<WarehouseFeeRule> {
|
||||||
|
constructor(@InjectRepository(WarehouseFeeRule) repository: Repository<WarehouseFeeRule>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||||
|
import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||||
|
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||||
|
|
||||||
|
interface ItemAttributes {
|
||||||
|
arrivedAt: Date | null;
|
||||||
|
gateClearedAt: Date | null;
|
||||||
|
releaseDate: Date | null;
|
||||||
|
freightType: string | null;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
cargoTypeCode: string | null;
|
||||||
|
containerTypeCode: string | null;
|
||||||
|
facilityId: string | null;
|
||||||
|
warehouseId: string | null;
|
||||||
|
yardId: string | null;
|
||||||
|
zoneId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeePreview {
|
||||||
|
ruleType: FeeRuleType;
|
||||||
|
ruleId: string | null;
|
||||||
|
ruleName: string | null;
|
||||||
|
freeDays: number;
|
||||||
|
ratePerDay: number;
|
||||||
|
currency: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string;
|
||||||
|
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
||||||
|
elapsedDays: number;
|
||||||
|
chargeableDays: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseFeeService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||||
|
listRules(): Promise<WarehouseFeeRule[]> {
|
||||||
|
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||||
|
return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
|
||||||
|
const updated = await this.feeRuleRepository.update(id, dto);
|
||||||
|
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRule(id: string): Promise<void> {
|
||||||
|
return this.feeRuleRepository.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||||
|
const [row] = await this.dataSource.query(
|
||||||
|
`SELECT inv.arrived_at AS "arrivedAt",
|
||||||
|
inv.gate_cleared_at AS "gateClearedAt",
|
||||||
|
inv.release_date AS "releaseDate",
|
||||||
|
inv.warehouse_id AS "warehouseId",
|
||||||
|
inv.yard_id AS "yardId",
|
||||||
|
inv.zone_id AS "zoneId",
|
||||||
|
w.facility_id AS "facilityId",
|
||||||
|
b.freight_type AS "freightType",
|
||||||
|
b.trade_direction AS "tradeDirection",
|
||||||
|
cgt.code AS "cargoTypeCode",
|
||||||
|
ctt.code AS "containerTypeCode"
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
|
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
||||||
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||||
|
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||||
|
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||||
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||||
|
[inventoryId],
|
||||||
|
);
|
||||||
|
if (!row) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
|
||||||
|
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
|
||||||
|
let score = 0;
|
||||||
|
const check = (ruleVal: string | null | undefined, itemVal: string | null) => {
|
||||||
|
if (ruleVal == null) return true;
|
||||||
|
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
|
||||||
|
score += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (!check(rule.freightType, item.freightType)) return null;
|
||||||
|
if (!check(rule.tradeDirection, item.tradeDirection)) return null;
|
||||||
|
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
|
||||||
|
if (!check(rule.containerType, item.containerTypeCode)) return null;
|
||||||
|
if (!check(rule.facilityId, item.facilityId)) return null;
|
||||||
|
if (!check(rule.warehouseId, item.warehouseId)) return null;
|
||||||
|
if (!check(rule.yardId, item.yardId)) return null;
|
||||||
|
if (!check(rule.zoneId, item.zoneId)) return null;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bestRule(rules: WarehouseFeeRule[], item: ItemAttributes): WarehouseFeeRule | null {
|
||||||
|
let best: WarehouseFeeRule | null = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const rule of rules) {
|
||||||
|
const score = this.matchScore(rule, item);
|
||||||
|
if (score == null) continue;
|
||||||
|
if (score > bestScore || (score === bestScore && best && rule.priority < best.priority)) {
|
||||||
|
best = rule;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): FeePreview {
|
||||||
|
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
||||||
|
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
||||||
|
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||||
|
const freeDays = rule?.freeDays ?? 0;
|
||||||
|
const ratePerDay = Number(rule?.ratePerDay ?? 0);
|
||||||
|
|
||||||
|
const elapsedDays = start
|
||||||
|
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||||
|
: 0;
|
||||||
|
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
||||||
|
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ruleType,
|
||||||
|
ruleId: rule?.id ?? null,
|
||||||
|
ruleName: rule?.name ?? null,
|
||||||
|
freeDays,
|
||||||
|
ratePerDay,
|
||||||
|
currency: rule?.currency ?? 'USD',
|
||||||
|
startDate: start ? start.toISOString() : null,
|
||||||
|
endDate: new Date(endDate).toISOString(),
|
||||||
|
endIsOpen,
|
||||||
|
elapsedDays,
|
||||||
|
chargeableDays,
|
||||||
|
amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||||
|
async previewForInventory(inventoryId: string): Promise<FeePreview[]> {
|
||||||
|
const item = await this.loadItem(inventoryId);
|
||||||
|
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
|
||||||
|
return byType.map((type) =>
|
||||||
|
this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UploadedFiles,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||||
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||||
|
|
||||||
|
@ApiTags('warehouse-inspection')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller()
|
||||||
|
export class WarehouseInspectionController {
|
||||||
|
constructor(private readonly inspectionService: WarehouseInspectionService) {}
|
||||||
|
|
||||||
|
@Post('warehouse-inventory/:inventoryId/inspection-reports')
|
||||||
|
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
|
||||||
|
create(
|
||||||
|
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
|
||||||
|
@Body() dto: CreateInspectionReportDto,
|
||||||
|
) {
|
||||||
|
return this.inspectionService.create(inventoryId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('warehouse-inventory/:inventoryId/inspection-reports')
|
||||||
|
@ApiOperation({ summary: 'List inspection reports for an inventory item' })
|
||||||
|
listByInventory(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
|
||||||
|
return this.inspectionService.findByInventory(inventoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('warehouse-inspection-reports/:id')
|
||||||
|
@ApiOperation({ summary: 'Get an inspection report (with attachments)' })
|
||||||
|
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const report = await this.inspectionService.findById(id);
|
||||||
|
const attachments = await this.inspectionService.listAttachments(id);
|
||||||
|
return { ...report, attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('warehouse-inspection-reports/:id')
|
||||||
|
@ApiOperation({ summary: 'Update an inspection report' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
|
||||||
|
return this.inspectionService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('warehouse-inspection-reports/:id/attachments')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Upload inspection images / documents' })
|
||||||
|
addAttachments(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.inspectionService.addAttachments(id, files);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseInspectionRepository extends BaseRepository<WarehouseInspectionReport> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WarehouseInspectionReport) repository: Repository<WarehouseInspectionReport>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||||
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
|
import { WarehouseInspectionRepository } from './warehouse-inspection.repository';
|
||||||
|
|
||||||
|
const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseInspectionService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Create an inspection report for an inventory item and sync its inspectionStatus. */
|
||||||
|
async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> {
|
||||||
|
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||||
|
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
|
||||||
|
if (!inventory) {
|
||||||
|
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = dto.expectedWeight ?? null;
|
||||||
|
const actual = dto.actualWeight ?? null;
|
||||||
|
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
||||||
|
|
||||||
|
const report = await this.inspectionRepository.create({
|
||||||
|
inventoryId,
|
||||||
|
bookingId: inventory.bookingId ?? null,
|
||||||
|
reportType: dto.reportType,
|
||||||
|
inspectionStatus: dto.inspectionStatus,
|
||||||
|
hasDamage: dto.hasDamage ?? false,
|
||||||
|
damageDescription: dto.damageDescription ?? null,
|
||||||
|
hasWeightLoss: dto.hasWeightLoss ?? false,
|
||||||
|
expectedWeight: expected,
|
||||||
|
actualWeight: actual,
|
||||||
|
weightLoss,
|
||||||
|
weightLossUnit: weightLoss !== null ? 'kg' : null,
|
||||||
|
hasMissingItems: dto.hasMissingItems ?? false,
|
||||||
|
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||||
|
remarks: dto.remarks ?? null,
|
||||||
|
inspectedById: dto.inspectedById ?? null,
|
||||||
|
inspectedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror the latest outcome onto the inventory item so loading rules can read it.
|
||||||
|
await inventoryRepo.update(inventoryId, {
|
||||||
|
inspectionStatus: dto.inspectionStatus,
|
||||||
|
inspectedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByInventory(inventoryId: string): Promise<WarehouseInspectionReport[]> {
|
||||||
|
return this.inspectionRepository.findAll({
|
||||||
|
where: { inventoryId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WarehouseInspectionReport> {
|
||||||
|
const report = await this.inspectionRepository.findById(id);
|
||||||
|
if (!report) {
|
||||||
|
throw new NotFoundException(`Inspection report ${id} not found`);
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateInspectionReportDto): Promise<WarehouseInspectionReport> {
|
||||||
|
const report = await this.findById(id);
|
||||||
|
|
||||||
|
const expected = dto.expectedWeight ?? report.expectedWeight ?? null;
|
||||||
|
const actual = dto.actualWeight ?? report.actualWeight ?? null;
|
||||||
|
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : report.weightLoss ?? null;
|
||||||
|
|
||||||
|
await this.inspectionRepository.update(id, {
|
||||||
|
...(dto.reportType ? { reportType: dto.reportType } : {}),
|
||||||
|
...(dto.inspectionStatus ? { inspectionStatus: dto.inspectionStatus } : {}),
|
||||||
|
...(dto.hasDamage !== undefined ? { hasDamage: dto.hasDamage } : {}),
|
||||||
|
...(dto.damageDescription !== undefined ? { damageDescription: dto.damageDescription } : {}),
|
||||||
|
...(dto.hasWeightLoss !== undefined ? { hasWeightLoss: dto.hasWeightLoss } : {}),
|
||||||
|
expectedWeight: expected,
|
||||||
|
actualWeight: actual,
|
||||||
|
weightLoss,
|
||||||
|
...(dto.hasMissingItems !== undefined ? { hasMissingItems: dto.hasMissingItems } : {}),
|
||||||
|
...(dto.missingItemsDescription !== undefined ? { missingItemsDescription: dto.missingItemsDescription } : {}),
|
||||||
|
...(dto.remarks !== undefined ? { remarks: dto.remarks } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.inspectionStatus) {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(WarehouseInventory)
|
||||||
|
.update(report.inventoryId, { inspectionStatus: dto.inspectionStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach uploaded images/documents to a report, reusing the shared Files (MinIO) module. */
|
||||||
|
async addAttachments(reportId: string, files: Express.Multer.File[]) {
|
||||||
|
await this.findById(reportId);
|
||||||
|
if (!files?.length) return [];
|
||||||
|
return this.filesService.uploadMany(reportId, INSPECTION_RESOURCE, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
listAttachments(reportId: string) {
|
||||||
|
return this.filesService.findByResource(reportId, INSPECTION_RESOURCE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseInventoryMovementRepository extends BaseRepository<WarehouseInventoryMovement> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WarehouseInventoryMovement) repository: Repository<WarehouseInventoryMovement>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||||
|
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||||
|
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||||
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
|
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||||
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
|
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
|
||||||
|
@ApiTags('warehouse-inventory')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('warehouse-inventory')
|
||||||
|
export class WarehouseInventoryController {
|
||||||
|
constructor(
|
||||||
|
private readonly inventoryService: WarehouseInventoryService,
|
||||||
|
private readonly scheduling: SchedulingReadFacade,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||||
|
findAll(@Query() filter: FilterWarehouseInventoryDto) {
|
||||||
|
return this.inventoryService.findAll(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('ready-for-loading')
|
||||||
|
@ApiOperation({ summary: 'List inventory ready for loading' })
|
||||||
|
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
|
||||||
|
return this.inventoryService.findReadyForLoading(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('inquiry')
|
||||||
|
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
|
||||||
|
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
|
||||||
|
return this.inventoryService.inquiry(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('arrival-queue')
|
||||||
|
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
|
||||||
|
arrivalQueue() {
|
||||||
|
return this.inventoryService.arrivalQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('auto-unload-arrived')
|
||||||
|
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||||
|
autoUnloadArrived() {
|
||||||
|
return this.inventoryService.autoUnloadArrived();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('auto-load-ready')
|
||||||
|
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
|
||||||
|
autoLoadReady() {
|
||||||
|
return this.inventoryService.autoLoadReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/unload')
|
||||||
|
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
|
||||||
|
unloadBooking(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: UnloadBookingDto,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.unloadBooking(bookingId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/gate-clearance')
|
||||||
|
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
||||||
|
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||||
|
return this.inventoryService.gateClearance(id, performedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('loadable-wagons')
|
||||||
|
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||||
|
loadableWagons() {
|
||||||
|
return this.scheduling.listLoadableWagons();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('booking/:bookingId/schedule')
|
||||||
|
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
|
||||||
|
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.scheduling.getBookingSchedule(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('receive')
|
||||||
|
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||||
|
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
||||||
|
return this.inventoryService.receive(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('reserve')
|
||||||
|
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
||||||
|
reserve(@Body() dto: ReserveInventoryDto) {
|
||||||
|
return this.inventoryService.reserve(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/movements')
|
||||||
|
@ApiOperation({ summary: 'Inventory movement history' })
|
||||||
|
movements(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.inventoryService.findMovements(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/activity')
|
||||||
|
@ApiOperation({ summary: 'Inventory activity log' })
|
||||||
|
activity(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.inventoryService.findActivity(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/loadings')
|
||||||
|
@ApiOperation({ summary: 'Loading records for an inventory item' })
|
||||||
|
loadings(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.inventoryService.findLoadingsByInventory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/move')
|
||||||
|
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
|
||||||
|
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
|
||||||
|
return this.inventoryService.move(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/store')
|
||||||
|
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
||||||
|
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||||
|
return this.inventoryService.store(id, performedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/ready-for-loading')
|
||||||
|
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||||
|
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||||
|
return this.inventoryService.readyForLoading(id, performedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/load')
|
||||||
|
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
|
||||||
|
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
|
||||||
|
return this.inventoryService.load(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/dispatch')
|
||||||
|
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||||
|
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||||
|
return this.inventoryService.dispatch(id, performedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseInventoryRepository extends BaseRepository<WarehouseInventory> {
|
||||||
|
constructor(@InjectRepository(WarehouseInventory) repository: Repository<WarehouseInventory>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,940 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
|
||||||
|
|
||||||
|
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||||
|
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||||
|
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||||
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
|
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||||
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
|
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||||
|
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||||
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
|
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||||
|
import {
|
||||||
|
WAREHOUSE_INVENTORY_TRANSITIONS,
|
||||||
|
WarehouseInventory,
|
||||||
|
WarehouseInventoryStatus,
|
||||||
|
} from './entities/warehouse-inventory.entity';
|
||||||
|
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||||
|
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||||
|
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||||
|
import { Warehouse } from './entities/warehouse.entity';
|
||||||
|
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||||
|
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||||
|
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||||
|
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||||
|
|
||||||
|
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||||
|
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||||
|
|
||||||
|
export interface InventoryInquiryResult {
|
||||||
|
id: string;
|
||||||
|
bookingId: string | null;
|
||||||
|
bookingNumber: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
containerNumber: string | null;
|
||||||
|
cargoType: string | null;
|
||||||
|
cargoDescription: string | null;
|
||||||
|
goodsId: string | null;
|
||||||
|
warehouse: { id: string; name: string; code: string } | null;
|
||||||
|
yard: { id: string; name: string; code: string } | null;
|
||||||
|
zone: { id: string; name: string; code: string } | null;
|
||||||
|
status: string;
|
||||||
|
quantity: number;
|
||||||
|
weight: number;
|
||||||
|
arrivedAt: Date | null;
|
||||||
|
readyForLoadingAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationNode {
|
||||||
|
maxWeight?: number | null;
|
||||||
|
capacityWeight?: number | null;
|
||||||
|
maxVolume?: number | null;
|
||||||
|
capacityContainers?: number | null;
|
||||||
|
currentWeight: number;
|
||||||
|
currentVolume: number;
|
||||||
|
currentContainers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch 4.5 result/queue shapes ────────────────────────────────────────────
|
||||||
|
interface ArrivalQueueRow {
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string;
|
||||||
|
customer: string | null;
|
||||||
|
cargo: string | null;
|
||||||
|
container: string | null;
|
||||||
|
arrivalDate: Date | null;
|
||||||
|
bookingStatus: string;
|
||||||
|
inventoryId: string | null;
|
||||||
|
currentStatus: string | null;
|
||||||
|
inspectionStatus: string | null;
|
||||||
|
facility: string | null;
|
||||||
|
warehouse: string | null;
|
||||||
|
yard: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArrivalQueueItem {
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string;
|
||||||
|
customer: string | null;
|
||||||
|
cargo: string | null;
|
||||||
|
container: string | null;
|
||||||
|
facility: string | null;
|
||||||
|
warehouse: string | null;
|
||||||
|
yard: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
inventoryId: string | null;
|
||||||
|
currentStatus: string | null;
|
||||||
|
arrivalDate: Date | null;
|
||||||
|
inspectionStatus: string | null;
|
||||||
|
unloaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DefaultLocation {
|
||||||
|
warehouseId: string;
|
||||||
|
yardId: string;
|
||||||
|
zoneId: string;
|
||||||
|
facilityId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoUnloadResult {
|
||||||
|
processedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoLoadResult {
|
||||||
|
loadedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
results: { inventoryId: string; status: string; reason?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseInventoryService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||||
|
private readonly loadingRepository: WarehouseLoadingRepository,
|
||||||
|
private readonly activityLog: WarehouseActivityLogService,
|
||||||
|
private readonly scheduling: SchedulingReadFacade,
|
||||||
|
private readonly allocation: WarehouseAllocationService,
|
||||||
|
private readonly invoices: WarehouseInvoiceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch 6 — final terminal release / gate clearance.
|
||||||
|
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||||||
|
* inspection / storage / loading steps — only the final release.
|
||||||
|
*/
|
||||||
|
async gateClearance(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||||
|
const item = await this.findById(id);
|
||||||
|
const blocking = await this.invoices.findBlockingInvoice(id);
|
||||||
|
if (blocking) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Warehouse demurrage/storage fee must be paid before terminal release.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
await this.inventoryRepository.update(id, {
|
||||||
|
gateClearedAt: now,
|
||||||
|
releaseDate: item.releaseDate ?? now,
|
||||||
|
});
|
||||||
|
await this.activityLog.record({
|
||||||
|
activityType: 'INVENTORY_DISPATCHED',
|
||||||
|
inventoryId: id,
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
description: 'Gate clearance / terminal release',
|
||||||
|
performedBy,
|
||||||
|
});
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Listing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||||
|
const base = {
|
||||||
|
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
|
||||||
|
...(filter.yardId ? { yardId: filter.yardId } : {}),
|
||||||
|
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
|
||||||
|
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||||||
|
...(filter.cargoId ? { cargoId: filter.cargoId } : {}),
|
||||||
|
...(filter.containerId ? { containerId: filter.containerId } : {}),
|
||||||
|
...(filter.goodsId ? { goodsId: filter.goodsId } : {}),
|
||||||
|
...(filter.status ? { status: filter.status } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = filter.search?.trim();
|
||||||
|
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||||
|
? { ...base, notes: ILike(`%${search}%`) }
|
||||||
|
: base;
|
||||||
|
|
||||||
|
return this.inventoryRepository.findAll({
|
||||||
|
where,
|
||||||
|
relations: { warehouse: { facility: true }, yard: true, zone: true, booking: true },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||||
|
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WarehouseInventory> {
|
||||||
|
const item = await this.inventoryRepository.findById(id, {
|
||||||
|
relations: { warehouse: true, yard: true, zone: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||||
|
|
||||||
|
/** Bookings whose goods have arrived and may be unloaded into the warehouse. */
|
||||||
|
private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT'];
|
||||||
|
|
||||||
|
/** Arrived bookings + their current inventory/inspection state (queue view). */
|
||||||
|
async arrivalQueue(): Promise<ArrivalQueueItem[]> {
|
||||||
|
const rows: ArrivalQueueRow[] = await this.dataSource.query(
|
||||||
|
`SELECT b.id AS "bookingId",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
company.name AS "customer",
|
||||||
|
b.cargo_free_text AS "cargo",
|
||||||
|
ct.container_number AS "container",
|
||||||
|
b.scheduled_date AS "arrivalDate",
|
||||||
|
b.status AS "bookingStatus",
|
||||||
|
inv.id AS "inventoryId",
|
||||||
|
inv.status AS "currentStatus",
|
||||||
|
inv.inspection_status AS "inspectionStatus",
|
||||||
|
fac.name AS "facility",
|
||||||
|
wh.name AS "warehouse",
|
||||||
|
yard.name AS "yard",
|
||||||
|
zone.name AS "zone"
|
||||||
|
FROM freight.bookings b
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||||
|
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||||
|
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||||
|
LEFT JOIN freight.facilities fac ON fac.id = wh.facility_id
|
||||||
|
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||||
|
WHERE b.status = ANY($1) AND b.deleted_at IS NULL
|
||||||
|
ORDER BY b.scheduled_date DESC NULLS LAST`,
|
||||||
|
[this.ARRIVED_BOOKING_STATUSES],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
bookingId: r.bookingId,
|
||||||
|
bookingReference: r.bookingReference,
|
||||||
|
customer: r.customer ?? null,
|
||||||
|
cargo: r.cargo ?? null,
|
||||||
|
container: r.container ?? null,
|
||||||
|
facility: r.facility ?? null,
|
||||||
|
warehouse: r.warehouse ?? null,
|
||||||
|
yard: r.yard ?? null,
|
||||||
|
zone: r.zone ?? null,
|
||||||
|
inventoryId: r.inventoryId ?? null,
|
||||||
|
currentStatus: r.currentStatus ?? null,
|
||||||
|
arrivalDate: r.arrivalDate ?? null,
|
||||||
|
inspectionStatus: r.inspectionStatus ?? null,
|
||||||
|
unloaded: Boolean(r.inventoryId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||||||
|
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
|
||||||
|
const [row]: DefaultLocation[] = await this.dataSource.query(
|
||||||
|
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
|
||||||
|
yard.id AS "yardId", zone.id AS "zoneId"
|
||||||
|
FROM freight.warehouses wh
|
||||||
|
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
|
||||||
|
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
|
||||||
|
WHERE wh.deleted_at IS NULL
|
||||||
|
ORDER BY wh.created_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */
|
||||||
|
async autoUnloadArrived(): Promise<AutoUnloadResult> {
|
||||||
|
const arrived: {
|
||||||
|
id: string;
|
||||||
|
weight: string | null;
|
||||||
|
freightType: string | null;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
cargoTypeCode: string | null;
|
||||||
|
}[] = await this.dataSource.query(
|
||||||
|
`SELECT b.id, b.cargo_total_weight_vgm AS weight,
|
||||||
|
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||||
|
cgt.code AS "cargoTypeCode"
|
||||||
|
FROM freight.bookings b
|
||||||
|
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||||
|
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
|
||||||
|
[this.ARRIVED_BOOKING_STATUSES],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: AutoUnloadResult = { processedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
|
||||||
|
|
||||||
|
if (arrived.length === 0) return result;
|
||||||
|
|
||||||
|
const fallback = await this.pickDefaultLocation();
|
||||||
|
|
||||||
|
for (const booking of arrived) {
|
||||||
|
try {
|
||||||
|
// Deterministic allocation by rules; fall back to default location if no rule resolves.
|
||||||
|
const allocated = await this.allocation.resolveLocation({
|
||||||
|
freightType: booking.freightType,
|
||||||
|
tradeDirection: booking.tradeDirection,
|
||||||
|
cargoTypeCode: booking.cargoTypeCode,
|
||||||
|
});
|
||||||
|
const location = allocated ?? fallback;
|
||||||
|
if (!location) {
|
||||||
|
result.failedCount += 1;
|
||||||
|
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const saved = await this.inventoryRepository.create({
|
||||||
|
warehouseId: location.warehouseId,
|
||||||
|
yardId: location.yardId,
|
||||||
|
zoneId: location.zoneId,
|
||||||
|
bookingId: booking.id,
|
||||||
|
quantity: 1,
|
||||||
|
weight: Number(booking.weight) || 0,
|
||||||
|
status: 'RECEIVED',
|
||||||
|
arrivedAt: new Date(),
|
||||||
|
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
||||||
|
});
|
||||||
|
result.processedCount += 1;
|
||||||
|
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'PROCESSED' });
|
||||||
|
} catch (error) {
|
||||||
|
result.failedCount += 1;
|
||||||
|
result.results.push({
|
||||||
|
bookingId: booking.id,
|
||||||
|
status: 'FAILED',
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||||
|
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||||
|
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||||
|
|
||||||
|
let location: DefaultLocation | null =
|
||||||
|
dto.warehouseId && dto.yardId && dto.zoneId
|
||||||
|
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
|
||||||
|
: null;
|
||||||
|
if (!location) location = await this.pickDefaultLocation();
|
||||||
|
if (!location) {
|
||||||
|
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrivedAt = dto.unloadedAt ? new Date(dto.unloadedAt) : new Date();
|
||||||
|
|
||||||
|
if (existing[0]) {
|
||||||
|
await this.inventoryRepository.update(existing[0].id, {
|
||||||
|
warehouseId: location.warehouseId,
|
||||||
|
yardId: location.yardId,
|
||||||
|
zoneId: location.zoneId,
|
||||||
|
status: 'RECEIVED',
|
||||||
|
arrivedAt,
|
||||||
|
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||||
|
});
|
||||||
|
return this.findById(existing[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await this.inventoryRepository.create({
|
||||||
|
warehouseId: location.warehouseId,
|
||||||
|
yardId: location.yardId,
|
||||||
|
zoneId: location.zoneId,
|
||||||
|
bookingId,
|
||||||
|
quantity: 1,
|
||||||
|
weight: 0,
|
||||||
|
status: 'RECEIVED',
|
||||||
|
arrivedAt,
|
||||||
|
notes: dto.notes ?? 'Unloaded',
|
||||||
|
});
|
||||||
|
return this.findById(saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */
|
||||||
|
async autoLoadReady(): Promise<AutoLoadResult> {
|
||||||
|
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
|
||||||
|
const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
||||||
|
|
||||||
|
for (const item of ready) {
|
||||||
|
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
|
||||||
|
if (bookingStatus !== 'PAID') {
|
||||||
|
result.skippedCount += 1;
|
||||||
|
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(WarehouseInventory).update(item.id, {
|
||||||
|
status: 'LOADED',
|
||||||
|
loadedAt: new Date(),
|
||||||
|
});
|
||||||
|
await this.activityLog.record(
|
||||||
|
{
|
||||||
|
activityType: 'INVENTORY_LOADED',
|
||||||
|
inventoryId: item.id,
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
description: 'Auto-loaded (PAID booking)',
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
result.loadedCount += 1;
|
||||||
|
result.results.push({ inventoryId: item.id, status: 'LOADED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Receive ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||||
|
const weight = Number(dto.weight) || 0;
|
||||||
|
const volume = Number(dto.volume) || 0;
|
||||||
|
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
|
||||||
|
|
||||||
|
const id = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||||
|
|
||||||
|
if (dto.bookingId) {
|
||||||
|
await this.assertBookingExists(manager, dto.bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
|
||||||
|
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||||||
|
this.assertCapacity('Zone', zone, weight, volume, containerCount);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||||
|
manager.getRepository(WarehouseInventory).create({
|
||||||
|
warehouseId: dto.warehouseId,
|
||||||
|
yardId: dto.yardId,
|
||||||
|
zoneId: dto.zoneId,
|
||||||
|
bookingId: dto.bookingId ?? null,
|
||||||
|
cargoId: dto.cargoId ?? null,
|
||||||
|
containerId: dto.containerId ?? null,
|
||||||
|
goodsId: dto.goodsId ?? null,
|
||||||
|
quantity: Number(dto.quantity) || 0,
|
||||||
|
weight,
|
||||||
|
volume: dto.volume ?? null,
|
||||||
|
status: 'RECEIVED',
|
||||||
|
arrivedAt: now,
|
||||||
|
notes: dto.notes?.trim() ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
|
||||||
|
|
||||||
|
await this.activityLog.record(
|
||||||
|
{
|
||||||
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
|
inventoryId: saved.id,
|
||||||
|
warehouseId: dto.warehouseId,
|
||||||
|
description: `Received ${weight}kg at warehouse location`,
|
||||||
|
performedBy: dto.performedBy,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
return saved.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||||
|
return this.transition(id, 'STORED', {
|
||||||
|
timestampField: 'storedAt',
|
||||||
|
activityType: 'INVENTORY_STORED',
|
||||||
|
description: 'Inventory stored',
|
||||||
|
performedBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reserve(dto: ReserveInventoryDto): Promise<WarehouseInventory> {
|
||||||
|
const item = await this.findById(dto.inventoryId);
|
||||||
|
|
||||||
|
if (item.status !== 'STORED') {
|
||||||
|
throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await this.getBookingStatus(dto.bookingId);
|
||||||
|
if (!status) {
|
||||||
|
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||||||
|
}
|
||||||
|
if (status !== 'PAID') {
|
||||||
|
throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(WarehouseInventory).update(dto.inventoryId, {
|
||||||
|
status: 'RESERVED',
|
||||||
|
bookingId: dto.bookingId,
|
||||||
|
reservedAt: new Date(),
|
||||||
|
});
|
||||||
|
await this.activityLog.record(
|
||||||
|
{
|
||||||
|
activityType: 'INVENTORY_RESERVED',
|
||||||
|
inventoryId: dto.inventoryId,
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
description: `Reserved for booking ${dto.bookingId}`,
|
||||||
|
performedBy: dto.performedBy,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(dto.inventoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readyForLoading(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||||
|
const item = await this.findById(id);
|
||||||
|
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||||||
|
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||||||
|
}
|
||||||
|
return this.transition(id, 'READY_FOR_LOADING', {
|
||||||
|
timestampField: 'readyForLoadingAt',
|
||||||
|
activityType: 'READY_FOR_LOADING',
|
||||||
|
description: 'Inventory ready for loading',
|
||||||
|
performedBy,
|
||||||
|
preloaded: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record.
|
||||||
|
* Reads wagon/schedule data read-only — never modifies scheduling.
|
||||||
|
*/
|
||||||
|
async load(id: string, dto: LoadInventoryDto): Promise<WarehouseInventory> {
|
||||||
|
const item = await this.findById(id);
|
||||||
|
|
||||||
|
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
|
||||||
|
this.assertTransition(item.status, 'LOADED');
|
||||||
|
|
||||||
|
// 2. inventory is at a valid warehouse/yard/zone location.
|
||||||
|
if (!item.warehouseId || !item.yardId || !item.zoneId) {
|
||||||
|
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. wagon must exist.
|
||||||
|
const wagon = await this.scheduling.findWagon(dto.wagonId);
|
||||||
|
if (!wagon) {
|
||||||
|
throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. wagon must be available, or already selected by an existing train schedule.
|
||||||
|
const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId);
|
||||||
|
if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. inventory must not already have a loading record.
|
||||||
|
const existing = await this.loadingRepository.findAll({ where: { warehouseInventoryId: id } });
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new BadRequestException('Inventory has already been loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedWeight = dto.loadedWeight ?? (Number(item.weight) || 0);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const now = new Date();
|
||||||
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
|
status: 'LOADED',
|
||||||
|
loadedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.getRepository(WarehouseLoading).save(
|
||||||
|
manager.getRepository(WarehouseLoading).create({
|
||||||
|
warehouseInventoryId: id,
|
||||||
|
bookingId: item.bookingId ?? null,
|
||||||
|
wagonId: dto.wagonId,
|
||||||
|
loadedAt: now,
|
||||||
|
loadedBy: dto.loadedBy ?? null,
|
||||||
|
loadedWeight,
|
||||||
|
notes: dto.notes?.trim() ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.activityLog.record(
|
||||||
|
{
|
||||||
|
activityType: 'INVENTORY_LOADED',
|
||||||
|
inventoryId: id,
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
description: `Loaded onto wagon ${wagon.wagonNumber}`,
|
||||||
|
performedBy: dto.loadedBy,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading records (Batch 3) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async findLoadings(
|
||||||
|
filter: { bookingId?: string; wagonId?: string },
|
||||||
|
): Promise<Array<WarehouseLoading & { wagonNumber: string | null }>> {
|
||||||
|
const where = {
|
||||||
|
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||||||
|
...(filter.wagonId ? { wagonId: filter.wagonId } : {}),
|
||||||
|
};
|
||||||
|
const loadings = await this.loadingRepository.findAll({
|
||||||
|
where,
|
||||||
|
relations: { inventory: { warehouse: true, yard: true, zone: true } },
|
||||||
|
order: { loadedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrich with wagon numbers (read-only lookup into the scheduling domain).
|
||||||
|
const wagonIds = [...new Set(loadings.map((l) => l.wagonId))];
|
||||||
|
const wagonNumbers = new Map<string, string>();
|
||||||
|
if (wagonIds.length > 0) {
|
||||||
|
const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
|
||||||
|
'SELECT id, wagon_number FROM freight.wagons WHERE id = ANY($1)',
|
||||||
|
[wagonIds],
|
||||||
|
);
|
||||||
|
rows.forEach((r) => wagonNumbers.set(r.id, r.wagon_number));
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadings.map((loading) =>
|
||||||
|
Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findLoadingsByInventory(inventoryId: string): Promise<WarehouseLoading[]> {
|
||||||
|
return this.loadingRepository.findAll({
|
||||||
|
where: { warehouseInventoryId: inventoryId },
|
||||||
|
order: { loadedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||||
|
const item = await this.findById(id);
|
||||||
|
this.assertTransition(item.status, 'DISPATCHED');
|
||||||
|
|
||||||
|
const weight = Number(item.weight) || 0;
|
||||||
|
const volume = Number(item.volume) || 0;
|
||||||
|
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
|
status: 'DISPATCHED',
|
||||||
|
dispatchedAt: new Date(),
|
||||||
|
});
|
||||||
|
// Item physically leaves the warehouse — free up capacity.
|
||||||
|
await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1);
|
||||||
|
await this.activityLog.record(
|
||||||
|
{
|
||||||
|
activityType: 'INVENTORY_DISPATCHED',
|
||||||
|
inventoryId: id,
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
description: 'Inventory dispatched',
|
||||||
|
performedBy,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Movement ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async move(id: string, dto: MoveInventoryDto): Promise<WarehouseInventory> {
|
||||||
|
const item = await this.findById(id);
|
||||||
|
if (item.status === 'DISPATCHED') {
|
||||||
|
throw new BadRequestException('Dispatched inventory cannot be moved');
|
||||||
|
}
|
||||||
|
|
||||||
|
const weight = Number(item.weight) || 0;
|
||||||
|
const volume = Number(item.volume) || 0;
|
||||||
|
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||||
|
|
||||||
|
const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId };
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const { warehouse } = await this.validateLocation(manager, dto);
|
||||||
|
|
||||||
|
// Capacity check at the destination (item is added there).
|
||||||
|
const dest = await this.loadLocation(manager, dto);
|
||||||
|
this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount);
|
||||||
|
this.assertCapacity('Yard', dest.yard, weight, volume, containerCount);
|
||||||
|
this.assertCapacity('Zone', dest.zone, weight, volume, containerCount);
|
||||||
|
|
||||||
|
// Free the old location, occupy the new one.
|
||||||
|
await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1);
|
||||||
|
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
|
||||||
|
|
||||||
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
|
warehouseId: dto.warehouseId,
|
||||||
|
yardId: dto.yardId,
|
||||||
|
zoneId: dto.zoneId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.getRepository(WarehouseInventoryMovement).save(
|
||||||
|
manager.getRepository(WarehouseInventoryMovement).create({
|
||||||
|
inventoryId: id,
|
||||||
|
fromWarehouseId: from.warehouseId,
|
||||||
|
fromYardId: from.yardId,
|
||||||
|
fromZoneId: from.zoneId,
|
||||||
|
toWarehouseId: dto.warehouseId,
|
||||||
|
toYardId: dto.yardId,
|
||||||
|
toZoneId: dto.zoneId,
|
||||||
|
remarks: dto.remarks?.trim() ?? null,
|
||||||
|
movedBy: dto.movedBy ?? 'system',
|
||||||
|
movedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.activityLog.record(
|
||||||
|
{
|
||||||
|
activityType: 'INVENTORY_MOVED',
|
||||||
|
inventoryId: id,
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
description: dto.remarks?.trim() || 'Inventory moved',
|
||||||
|
performedBy: dto.movedBy,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
findMovements(id: string): Promise<WarehouseInventoryMovement[]> {
|
||||||
|
return this.dataSource.getRepository(WarehouseInventoryMovement).find({
|
||||||
|
where: { inventoryId: id },
|
||||||
|
order: { movedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findActivity(id: string): Promise<WarehouseActivityLog[]> {
|
||||||
|
return this.activityLog.findByInventory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inquiry (Batch 1) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async inquiry(filter: InquiryWarehouseInventoryDto): Promise<InventoryInquiryResult[]> {
|
||||||
|
const qb = this.dataSource
|
||||||
|
.getRepository(WarehouseInventory)
|
||||||
|
.createQueryBuilder('inv')
|
||||||
|
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||||
|
.leftJoinAndSelect('inv.yard', 'yard')
|
||||||
|
.leftJoinAndSelect('inv.zone', 'zone')
|
||||||
|
.leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
||||||
|
.leftJoin('freight.companies', 'company', 'company.id = booking.company_id')
|
||||||
|
.leftJoin('freight.containers', 'container', 'container.id = inv.container_id')
|
||||||
|
.leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id')
|
||||||
|
.leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
|
||||||
|
.addSelect('booking.reference', 'b_reference')
|
||||||
|
.addSelect('company.name', 'c_name')
|
||||||
|
.addSelect('container.container_number', 'ct_number')
|
||||||
|
.addSelect('cargo.description', 'cg_description')
|
||||||
|
.addSelect('cargo_type.cargo_type_name', 'cgt_name')
|
||||||
|
.orderBy('inv.created_at', 'DESC');
|
||||||
|
|
||||||
|
if (filter.bookingNumber?.trim()) {
|
||||||
|
qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` });
|
||||||
|
}
|
||||||
|
if (filter.containerNumber?.trim()) {
|
||||||
|
qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` });
|
||||||
|
}
|
||||||
|
if (filter.cargoType?.trim()) {
|
||||||
|
qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` });
|
||||||
|
}
|
||||||
|
if (filter.goodsName?.trim()) {
|
||||||
|
qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` });
|
||||||
|
}
|
||||||
|
if (filter.warehouseId) qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId });
|
||||||
|
if (filter.yardId) qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId });
|
||||||
|
if (filter.zoneId) qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId });
|
||||||
|
if (filter.status) qb.andWhere('inv.status = :status', { status: filter.status });
|
||||||
|
|
||||||
|
const { entities, raw } = await qb.getRawAndEntities();
|
||||||
|
|
||||||
|
return entities.map((inv, index) => {
|
||||||
|
const row = raw[index] ?? {};
|
||||||
|
return {
|
||||||
|
id: inv.id,
|
||||||
|
bookingId: inv.bookingId ?? null,
|
||||||
|
bookingNumber: row.b_reference ?? null,
|
||||||
|
customerName: row.c_name ?? null,
|
||||||
|
containerNumber: row.ct_number ?? null,
|
||||||
|
cargoType: row.cgt_name ?? null,
|
||||||
|
cargoDescription: row.cg_description ?? null,
|
||||||
|
goodsId: inv.goodsId ?? null,
|
||||||
|
warehouse: inv.warehouse
|
||||||
|
? { id: inv.warehouse.id, name: inv.warehouse.name, code: inv.warehouse.code }
|
||||||
|
: null,
|
||||||
|
yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null,
|
||||||
|
zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null,
|
||||||
|
status: inv.status,
|
||||||
|
quantity: Number(inv.quantity),
|
||||||
|
weight: Number(inv.weight),
|
||||||
|
arrivedAt: inv.arrivedAt ?? null,
|
||||||
|
readyForLoadingAt: inv.readyForLoadingAt ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async transition(
|
||||||
|
id: string,
|
||||||
|
to: WarehouseInventoryStatus,
|
||||||
|
opts: {
|
||||||
|
timestampField: keyof WarehouseInventory;
|
||||||
|
activityType: Parameters<WarehouseActivityLogService['record']>[0]['activityType'];
|
||||||
|
description: string;
|
||||||
|
performedBy?: string;
|
||||||
|
preloaded?: WarehouseInventory;
|
||||||
|
},
|
||||||
|
): Promise<WarehouseInventory> {
|
||||||
|
const item = opts.preloaded ?? (await this.findById(id));
|
||||||
|
this.assertTransition(item.status, to);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
|
status: to,
|
||||||
|
[opts.timestampField]: new Date(),
|
||||||
|
});
|
||||||
|
await this.activityLog.record(
|
||||||
|
{
|
||||||
|
activityType: opts.activityType,
|
||||||
|
inventoryId: id,
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
description: opts.description,
|
||||||
|
performedBy: opts.performedBy,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
|
||||||
|
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
|
||||||
|
throw new BadRequestException(`Invalid transition ${from} → ${to}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateLocation(
|
||||||
|
manager: EntityManager,
|
||||||
|
dto: { warehouseId: string; yardId: string; zoneId: string },
|
||||||
|
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
|
||||||
|
const { warehouse, yard, zone } = await this.loadLocation(manager, dto);
|
||||||
|
|
||||||
|
if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE');
|
||||||
|
if (yard.warehouseId !== warehouse.id) throw new BadRequestException('Yard does not belong to the selected warehouse');
|
||||||
|
if (yard.status !== 'ACTIVE') throw new BadRequestException('Yard is not ACTIVE');
|
||||||
|
if (zone.yardId !== yard.id) throw new BadRequestException('Zone does not belong to the selected yard');
|
||||||
|
if (zone.status !== 'ACTIVE') throw new BadRequestException('Zone is not ACTIVE');
|
||||||
|
|
||||||
|
return { warehouse, yard, zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadLocation(
|
||||||
|
manager: EntityManager,
|
||||||
|
dto: { warehouseId: string; yardId: string; zoneId: string },
|
||||||
|
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
|
||||||
|
const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } });
|
||||||
|
if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`);
|
||||||
|
const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } });
|
||||||
|
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
|
||||||
|
const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
|
||||||
|
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
|
||||||
|
return { warehouse, yard, zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertBookingExists(manager: EntityManager, bookingId: string): Promise<void> {
|
||||||
|
const rows = await manager.query(
|
||||||
|
'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBookingStatus(bookingId: string): Promise<string | null> {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows?.[0]?.status ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCapacity(
|
||||||
|
label: string,
|
||||||
|
node: LocationNode,
|
||||||
|
weightAdd: number,
|
||||||
|
volumeAdd: number,
|
||||||
|
containerAdd: number,
|
||||||
|
): void {
|
||||||
|
const maxWeight = node.maxWeight ?? node.capacityWeight;
|
||||||
|
if (maxWeight != null) {
|
||||||
|
const projected = Number(node.currentWeight) + weightAdd;
|
||||||
|
if (projected > Number(maxWeight)) {
|
||||||
|
throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.maxVolume != null && volumeAdd > 0) {
|
||||||
|
const projected = Number(node.currentVolume) + volumeAdd;
|
||||||
|
if (projected > Number(node.maxVolume)) {
|
||||||
|
throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.capacityContainers != null && containerAdd > 0) {
|
||||||
|
const projected = Number(node.currentContainers) + containerAdd;
|
||||||
|
if (projected > Number(node.capacityContainers)) {
|
||||||
|
throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyCapacityDelta(
|
||||||
|
manager: EntityManager,
|
||||||
|
warehouseId: string,
|
||||||
|
yardId: string,
|
||||||
|
zoneId: string,
|
||||||
|
weight: number,
|
||||||
|
volume: number,
|
||||||
|
containers: number,
|
||||||
|
sign: 1 | -1,
|
||||||
|
): Promise<void> {
|
||||||
|
const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager);
|
||||||
|
const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [
|
||||||
|
[Warehouse, warehouseId],
|
||||||
|
[WarehouseYard, yardId],
|
||||||
|
[WarehouseZone, zoneId],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [entity, id] of targets) {
|
||||||
|
if (weight) await apply(entity, { id }, 'currentWeight', weight);
|
||||||
|
if (volume) await apply(entity, { id }, 'currentVolume', volume);
|
||||||
|
if (containers) await apply(entity, { id }, 'currentContainers', containers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||||
|
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||||
|
|
||||||
|
@ApiTags('warehouse-fee-invoices')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller()
|
||||||
|
export class WarehouseInvoiceController {
|
||||||
|
constructor(private readonly invoiceService: WarehouseInvoiceService) {}
|
||||||
|
|
||||||
|
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
||||||
|
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
||||||
|
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
|
||||||
|
return this.invoiceService.generateForInventory(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('warehouse-inventory/:id/fee-invoices')
|
||||||
|
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
|
||||||
|
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.invoiceService.listForInventory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:id/warehouse-fee-invoices')
|
||||||
|
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
|
||||||
|
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.invoiceService.listForBooking(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('warehouse-fee-invoices')
|
||||||
|
@ApiOperation({ summary: 'List / filter warehouse fee invoices' })
|
||||||
|
findAll(
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('invoiceType') invoiceType?: string,
|
||||||
|
@Query('warehouseId') warehouseId?: string,
|
||||||
|
@Query('facilityId') facilityId?: string,
|
||||||
|
@Query('customerId') customerId?: string,
|
||||||
|
@Query('bookingId') bookingId?: string,
|
||||||
|
) {
|
||||||
|
return this.invoiceService.findAll({
|
||||||
|
status: status as never,
|
||||||
|
invoiceType: invoiceType as never,
|
||||||
|
warehouseId,
|
||||||
|
facilityId,
|
||||||
|
customerId,
|
||||||
|
bookingId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('warehouse-fee-invoices/:id')
|
||||||
|
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.invoiceService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('warehouse-fee-invoices/:id/cancel')
|
||||||
|
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
|
||||||
|
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.invoiceService.cancel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('warehouse-fee-invoices/:id/pay')
|
||||||
|
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
|
||||||
|
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
|
||||||
|
return this.invoiceService.pay(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WarehouseFeeInvoice,
|
||||||
|
WarehouseInvoiceStatus,
|
||||||
|
WarehouseInvoiceType,
|
||||||
|
} from './entities/warehouse-fee-invoice.entity';
|
||||||
|
import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
|
||||||
|
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||||
|
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||||
|
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||||
|
|
||||||
|
interface GenerateOptions {
|
||||||
|
confirmZero?: boolean;
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayInvoiceDto {
|
||||||
|
amount: number;
|
||||||
|
method?: string;
|
||||||
|
reference?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoices that still owe money and therefore block terminal release. */
|
||||||
|
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
|
||||||
|
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseInvoiceService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||||
|
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||||
|
private readonly feeService: WarehouseFeeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Generation ───────────────────────────────────────────────────────────
|
||||||
|
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
|
||||||
|
const [item] = await this.dataSource.query(
|
||||||
|
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
||||||
|
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
|
||||||
|
w.facility_id AS "facilityId",
|
||||||
|
b.company_id AS "customerId", b.freight_type AS "freightType"
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||||
|
[inventoryId],
|
||||||
|
);
|
||||||
|
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||||
|
|
||||||
|
// Dedup: only one active (non-cancelled) invoice per inventory item.
|
||||||
|
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||||
|
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previews = await this.feeService.previewForInventory(inventoryId);
|
||||||
|
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||||
|
|
||||||
|
const items = previews
|
||||||
|
.filter((p) => p.amount > 0)
|
||||||
|
.map((p) => {
|
||||||
|
const feeType: WarehouseFeeType =
|
||||||
|
p.ruleType === 'STORAGE_FEE'
|
||||||
|
? 'STORAGE_FEE'
|
||||||
|
: isContainer
|
||||||
|
? 'CONTAINER_DEMURRAGE'
|
||||||
|
: 'BULK_DEMURRAGE';
|
||||||
|
return {
|
||||||
|
feeRuleId: p.ruleId,
|
||||||
|
feeType,
|
||||||
|
description:
|
||||||
|
p.ruleType === 'STORAGE_FEE'
|
||||||
|
? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
|
||||||
|
: `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`,
|
||||||
|
quantity: p.chargeableDays,
|
||||||
|
unitRate: p.ratePerDay,
|
||||||
|
amount: p.amount,
|
||||||
|
currency: p.currency,
|
||||||
|
chargeableDays: p.chargeableDays,
|
||||||
|
freeDays: p.freeDays,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtotal = items.reduce((s, i) => s + i.amount, 0);
|
||||||
|
const total = subtotal; // tax model can be layered on later
|
||||||
|
|
||||||
|
if (total <= 0 && !opts.confirmZero) {
|
||||||
|
throw new BadRequestException('No payable warehouse fee found for this item.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE');
|
||||||
|
const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE');
|
||||||
|
const invoiceType: WarehouseInvoiceType =
|
||||||
|
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||||
|
|
||||||
|
const currency = items[0]?.currency ?? 'USD';
|
||||||
|
const now = new Date();
|
||||||
|
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
|
||||||
|
|
||||||
|
const invoice = await this.invoiceRepository.create({
|
||||||
|
invoiceNumber: await this.nextInvoiceNumber(),
|
||||||
|
bookingId: item.bookingId ?? null,
|
||||||
|
customerId: item.customerId ?? null,
|
||||||
|
inventoryId,
|
||||||
|
facilityId: item.facilityId ?? null,
|
||||||
|
warehouseId: item.warehouseId ?? null,
|
||||||
|
yardId: item.yardId ?? null,
|
||||||
|
zoneId: item.zoneId ?? null,
|
||||||
|
invoiceType,
|
||||||
|
status: 'ISSUED',
|
||||||
|
subtotalAmount: subtotal,
|
||||||
|
taxAmount: 0,
|
||||||
|
totalAmount: total,
|
||||||
|
paidAmount: 0,
|
||||||
|
balanceAmount: total,
|
||||||
|
currency,
|
||||||
|
periodStart: item.arrivedAt ?? null,
|
||||||
|
periodEnd,
|
||||||
|
issuedAt: now,
|
||||||
|
payments: [],
|
||||||
|
notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(invoice.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
||||||
|
private async nextInvoiceNumber(): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
const prefix = `WHF-${ymd}-`;
|
||||||
|
const [row] = await this.dataSource.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||||
|
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
|
||||||
|
[`${prefix}%`],
|
||||||
|
);
|
||||||
|
const next = Number(row?.seq ?? 0) + 1;
|
||||||
|
return `${prefix}${String(next).padStart(5, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reads ────────────────────────────────────────────────────────────────
|
||||||
|
async findById(id: string): Promise<WarehouseFeeInvoice & { items: unknown[] }> {
|
||||||
|
const invoice = await this.invoiceRepository.findById(id);
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
|
||||||
|
return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
|
||||||
|
return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
listForBooking(bookingId: string): Promise<WarehouseFeeInvoice[]> {
|
||||||
|
return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(filter: Partial<Pick<WarehouseFeeInvoice, 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'>>): Promise<WarehouseFeeInvoice[]> {
|
||||||
|
const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null));
|
||||||
|
return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State changes ────────────────────────────────────────────────────────
|
||||||
|
async cancel(id: string): Promise<WarehouseFeeInvoice> {
|
||||||
|
const invoice = await this.invoiceRepository.findById(id);
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
|
||||||
|
const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() });
|
||||||
|
return updated as WarehouseFeeInvoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a payment against the invoice and sync status (links to existing payment flow). */
|
||||||
|
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
|
||||||
|
const invoice = await this.invoiceRepository.findById(id);
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
|
||||||
|
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
|
||||||
|
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
|
||||||
|
|
||||||
|
const paidAmount = Number(invoice.paidAmount) + dto.amount;
|
||||||
|
const total = Number(invoice.totalAmount);
|
||||||
|
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
|
||||||
|
const fullyPaid = paidAmount >= total;
|
||||||
|
|
||||||
|
const payments = [
|
||||||
|
...(invoice.payments ?? []),
|
||||||
|
{ amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const updated = await this.invoiceRepository.update(id, {
|
||||||
|
paidAmount: Math.round(paidAmount * 100) / 100,
|
||||||
|
balanceAmount: balance,
|
||||||
|
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
|
||||||
|
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||||
|
payments,
|
||||||
|
});
|
||||||
|
return updated as WarehouseFeeInvoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Release blocking ──────────────────────────────────────────────────────
|
||||||
|
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
||||||
|
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
|
||||||
|
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
||||||
|
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseLoadingRepository extends BaseRepository<WarehouseLoading> {
|
||||||
|
constructor(@InjectRepository(WarehouseLoading) repository: Repository<WarehouseLoading>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
|
||||||
|
@ApiTags('warehouse-loadings')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('warehouse-loadings')
|
||||||
|
export class WarehouseLoadingsController {
|
||||||
|
constructor(private readonly inventoryService: WarehouseInventoryService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List wagon loading records' })
|
||||||
|
findAll(@Query('bookingId') bookingId?: string, @Query('wagonId') wagonId?: string) {
|
||||||
|
return this.inventoryService.findLoadings({ bookingId, wagonId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AllocationPreviewDto,
|
||||||
|
CreateAllocationRuleDto,
|
||||||
|
UpdateAllocationRuleDto,
|
||||||
|
} from './dto/allocation-rule.dto';
|
||||||
|
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||||
|
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||||
|
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||||
|
|
||||||
|
@ApiTags('warehouse-rules')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller()
|
||||||
|
export class WarehouseRulesController {
|
||||||
|
constructor(
|
||||||
|
private readonly allocationService: WarehouseAllocationService,
|
||||||
|
private readonly feeService: WarehouseFeeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Allocation rules ───────────────────────────────────────────────────────
|
||||||
|
@Get('warehouse-allocation-rules')
|
||||||
|
@ApiOperation({ summary: 'List warehouse allocation rules' })
|
||||||
|
listAllocationRules() {
|
||||||
|
return this.allocationService.listRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('warehouse-allocation-rules')
|
||||||
|
@ApiOperation({ summary: 'Create a warehouse allocation rule' })
|
||||||
|
createAllocationRule(@Body() dto: CreateAllocationRuleDto) {
|
||||||
|
return this.allocationService.createRule(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('warehouse-allocation-rules/:id')
|
||||||
|
@ApiOperation({ summary: 'Update a warehouse allocation rule' })
|
||||||
|
updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) {
|
||||||
|
return this.allocationService.updateRule(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('warehouse-allocation-rules/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({ summary: 'Delete a warehouse allocation rule' })
|
||||||
|
deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.allocationService.deleteRule(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('warehouse-allocation/preview')
|
||||||
|
@ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' })
|
||||||
|
previewAllocation(@Body() dto: AllocationPreviewDto) {
|
||||||
|
return this.allocationService.resolveLocation(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fee rules ────────────────────────────────────────────────────────────────
|
||||||
|
@Get('warehouse-fee-rules')
|
||||||
|
@ApiOperation({ summary: 'List storage / demurrage fee rules' })
|
||||||
|
listFeeRules() {
|
||||||
|
return this.feeService.listRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('warehouse-fee-rules')
|
||||||
|
@ApiOperation({ summary: 'Create a storage / demurrage fee rule' })
|
||||||
|
createFeeRule(@Body() dto: CreateFeeRuleDto) {
|
||||||
|
return this.feeService.createRule(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('warehouse-fee-rules/:id')
|
||||||
|
@ApiOperation({ summary: 'Update a fee rule' })
|
||||||
|
updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) {
|
||||||
|
return this.feeService.updateRule(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('warehouse-fee-rules/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({ summary: 'Delete a fee rule' })
|
||||||
|
deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.feeService.deleteRule(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('warehouse-inventory/:id/fee-preview')
|
||||||
|
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||||
|
feePreview(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.feeService.previewForInventory(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READ-ONLY bridge that exposes warehouse inventory to the Train Scheduling
|
||||||
|
* domain. It only reads warehouse data — it never assigns wagons, creates or
|
||||||
|
* mutates schedules, and is intentionally NOT imported by the scheduling module.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseSchedulingAdapterService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
private get repo() {
|
||||||
|
return this.dataSource.getRepository(WarehouseInventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
getReadyForLoadingInventory(): Promise<WarehouseInventory[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { status: 'READY_FOR_LOADING' },
|
||||||
|
relations: { warehouse: true, yard: true, zone: true },
|
||||||
|
order: { readyForLoadingAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getReservedInventory(): Promise<WarehouseInventory[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { status: 'RESERVED' },
|
||||||
|
relations: { warehouse: true, yard: true, zone: true },
|
||||||
|
order: { reservedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getInventoryByBooking(bookingId: string): Promise<WarehouseInventory[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { bookingId },
|
||||||
|
relations: { warehouse: true, yard: true, zone: true },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory whose origin booking runs on the given route. Best-effort, read-only:
|
||||||
|
* matches the route's origin/destination yards against the booking's yards.
|
||||||
|
*/
|
||||||
|
async getInventoryByRoute(routeId: string): Promise<WarehouseInventory[]> {
|
||||||
|
return this.repo
|
||||||
|
.createQueryBuilder('inv')
|
||||||
|
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||||
|
.leftJoinAndSelect('inv.yard', 'yard')
|
||||||
|
.leftJoinAndSelect('inv.zone', 'zone')
|
||||||
|
.innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
||||||
|
.innerJoin(
|
||||||
|
'freight.routes',
|
||||||
|
'route',
|
||||||
|
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
|
||||||
|
{ routeId },
|
||||||
|
)
|
||||||
|
.orderBy('inv.created_at', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||||
|
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||||
|
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||||
|
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||||
|
|
||||||
|
@ApiTags('warehouse-yards')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('warehouse-yards')
|
||||||
|
export class WarehouseYardsController {
|
||||||
|
constructor(
|
||||||
|
private readonly yardsService: WarehouseYardsService,
|
||||||
|
private readonly zonesService: WarehouseZonesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.yardsService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update warehouse yard' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
|
||||||
|
return this.yardsService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':yardId/zones')
|
||||||
|
@ApiOperation({ summary: 'List zones within a yard' })
|
||||||
|
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
|
||||||
|
return this.zonesService.findByYard(yardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':yardId/zones')
|
||||||
|
@ApiOperation({ summary: 'Create a zone within a yard' })
|
||||||
|
createZone(
|
||||||
|
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||||
|
@Body() dto: CreateWarehouseZoneDto,
|
||||||
|
) {
|
||||||
|
return this.zonesService.create(yardId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseYardsRepository extends BaseRepository<WarehouseYard> {
|
||||||
|
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||||
|
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||||
|
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||||
|
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||||
|
import { WarehousesService } from './warehouses.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseYardsService {
|
||||||
|
constructor(
|
||||||
|
private readonly yardsRepository: WarehouseYardsRepository,
|
||||||
|
private readonly warehousesService: WarehousesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||||
|
return this.yardsRepository.findAll({
|
||||||
|
where: { warehouseId },
|
||||||
|
relations: { zones: true },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WarehouseYard> {
|
||||||
|
const yard = await this.yardsRepository.findById(id, {
|
||||||
|
relations: { warehouse: true, zones: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!yard) {
|
||||||
|
throw new NotFoundException(`Warehouse yard ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return yard;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(warehouseId: string, dto: CreateWarehouseYardDto): Promise<WarehouseYard> {
|
||||||
|
// Ensure the parent warehouse exists.
|
||||||
|
await this.warehousesService.findById(warehouseId);
|
||||||
|
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
||||||
|
|
||||||
|
return this.yardsRepository.create({
|
||||||
|
warehouseId,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
code: dto.code.trim(),
|
||||||
|
type: dto.type,
|
||||||
|
capacityWeight: dto.capacityWeight ?? null,
|
||||||
|
capacityContainers: dto.capacityContainers ?? null,
|
||||||
|
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||||
|
maxVolume: dto.maxVolume ?? null,
|
||||||
|
currentWeight: 0,
|
||||||
|
currentContainers: 0,
|
||||||
|
currentVolume: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateWarehouseYardDto): Promise<WarehouseYard> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.code && dto.code.trim() !== existing.code) {
|
||||||
|
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = dto.status ?? existing.status;
|
||||||
|
|
||||||
|
const updated = await this.yardsRepository.update(id, {
|
||||||
|
name: dto.name?.trim() ?? existing.name,
|
||||||
|
code: dto.code?.trim() ?? existing.code,
|
||||||
|
type: dto.type ?? existing.type,
|
||||||
|
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||||
|
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||||
|
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||||
|
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||||
|
status,
|
||||||
|
isActive: status === 'ACTIVE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Warehouse yard ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
|
||||||
|
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
||||||
|
|
||||||
|
if (existing && existing.id !== ignoreId) {
|
||||||
|
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||||
|
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||||
|
|
||||||
|
@ApiTags('warehouse-zones')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('warehouse-zones')
|
||||||
|
export class WarehouseZonesController {
|
||||||
|
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.zonesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update warehouse zone' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||||
|
return this.zonesService.update(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseZonesRepository extends BaseRepository<WarehouseZone> {
|
||||||
|
constructor(@InjectRepository(WarehouseZone) repository: Repository<WarehouseZone>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||||
|
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||||
|
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||||
|
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||||
|
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehouseZonesService {
|
||||||
|
constructor(
|
||||||
|
private readonly zonesRepository: WarehouseZonesRepository,
|
||||||
|
private readonly yardsService: WarehouseYardsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
||||||
|
return this.zonesRepository.findAll({
|
||||||
|
where: { yardId },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WarehouseZone> {
|
||||||
|
const zone = await this.zonesRepository.findById(id, {
|
||||||
|
relations: { yard: { warehouse: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!zone) {
|
||||||
|
throw new NotFoundException(`Warehouse zone ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(yardId: string, dto: CreateWarehouseZoneDto): Promise<WarehouseZone> {
|
||||||
|
// Ensure the parent yard exists.
|
||||||
|
await this.yardsService.findById(yardId);
|
||||||
|
await this.assertCodeUnique(yardId, dto.code.trim());
|
||||||
|
|
||||||
|
return this.zonesRepository.create({
|
||||||
|
yardId,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
code: dto.code.trim(),
|
||||||
|
type: dto.type,
|
||||||
|
capacityWeight: dto.capacityWeight ?? null,
|
||||||
|
capacityContainers: dto.capacityContainers ?? null,
|
||||||
|
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||||
|
maxVolume: dto.maxVolume ?? null,
|
||||||
|
currentWeight: 0,
|
||||||
|
currentContainers: 0,
|
||||||
|
currentVolume: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateWarehouseZoneDto): Promise<WarehouseZone> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.code && dto.code.trim() !== existing.code) {
|
||||||
|
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = dto.status ?? existing.status;
|
||||||
|
|
||||||
|
const updated = await this.zonesRepository.update(id, {
|
||||||
|
name: dto.name?.trim() ?? existing.name,
|
||||||
|
code: dto.code?.trim() ?? existing.code,
|
||||||
|
type: dto.type ?? existing.type,
|
||||||
|
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||||
|
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||||
|
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||||
|
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||||
|
status,
|
||||||
|
isActive: status === 'ACTIVE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Warehouse zone ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
||||||
|
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
||||||
|
|
||||||
|
if (existing && existing.id !== ignoreId) {
|
||||||
|
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||||
|
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||||
|
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||||
|
import { UpdateWarehouseDto } from './dto/update-warehouse.dto';
|
||||||
|
import { WarehouseDashboardService } from './warehouse-dashboard.service';
|
||||||
|
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||||
|
import { WarehousesService } from './warehouses.service';
|
||||||
|
|
||||||
|
@ApiTags('warehouses')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('warehouses')
|
||||||
|
export class WarehousesController {
|
||||||
|
constructor(
|
||||||
|
private readonly warehousesService: WarehousesService,
|
||||||
|
private readonly yardsService: WarehouseYardsService,
|
||||||
|
private readonly dashboardService: WarehouseDashboardService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List warehouses' })
|
||||||
|
findAll(@Query() filter: FilterWarehouseDto) {
|
||||||
|
return this.warehousesService.findAll(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
|
||||||
|
dashboard() {
|
||||||
|
return this.dashboardService.getDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create warehouse' })
|
||||||
|
create(@Body() dto: CreateWarehouseDto) {
|
||||||
|
return this.warehousesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get warehouse by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.warehousesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update warehouse' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
|
||||||
|
return this.warehousesService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':warehouseId/yards')
|
||||||
|
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||||
|
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
|
||||||
|
return this.yardsService.findByWarehouse(warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':warehouseId/yards')
|
||||||
|
@ApiOperation({ summary: 'Create a yard within a warehouse' })
|
||||||
|
createYard(
|
||||||
|
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,
|
||||||
|
@Body() dto: CreateWarehouseYardDto,
|
||||||
|
) {
|
||||||
|
return this.yardsService.create(warehouseId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
Normal file
114
apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
|
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||||
|
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
|
||||||
|
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||||
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
|
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||||
|
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||||
|
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||||
|
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||||
|
import { Warehouse } from './entities/warehouse.entity';
|
||||||
|
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||||
|
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||||
|
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||||
|
import { WarehouseDashboardService } from './warehouse-dashboard.service';
|
||||||
|
import { WarehouseInspectionController } from './warehouse-inspection.controller';
|
||||||
|
import { WarehouseInspectionRepository } from './warehouse-inspection.repository';
|
||||||
|
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||||
|
import { WarehouseInventoryController } from './warehouse-inventory.controller';
|
||||||
|
import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository';
|
||||||
|
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||||
|
import { WarehouseLoadingsController } from './warehouse-loadings.controller';
|
||||||
|
import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository';
|
||||||
|
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||||
|
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||||
|
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||||
|
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
|
||||||
|
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
|
||||||
|
import { WarehouseInvoiceController } from './warehouse-invoice.controller';
|
||||||
|
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||||
|
import { WarehouseRulesController } from './warehouse-rules.controller';
|
||||||
|
import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service';
|
||||||
|
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||||
|
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||||
|
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||||
|
import { WarehouseZonesController } from './warehouse-zones.controller';
|
||||||
|
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||||
|
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||||
|
import { WarehousesController } from './warehouses.controller';
|
||||||
|
import { WarehousesRepository } from './warehouses.repository';
|
||||||
|
import { WarehousesService } from './warehouses.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Warehouse,
|
||||||
|
WarehouseYard,
|
||||||
|
WarehouseZone,
|
||||||
|
WarehouseInventory,
|
||||||
|
WarehouseInventoryMovement,
|
||||||
|
WarehouseActivityLog,
|
||||||
|
WarehouseLoading,
|
||||||
|
WarehouseInspectionReport,
|
||||||
|
WarehouseAllocationRule,
|
||||||
|
WarehouseFeeRule,
|
||||||
|
WarehouseFeeInvoice,
|
||||||
|
WarehouseFeeInvoiceItem,
|
||||||
|
]),
|
||||||
|
FilesModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
WarehousesController,
|
||||||
|
WarehouseYardsController,
|
||||||
|
WarehouseZonesController,
|
||||||
|
WarehouseInventoryController,
|
||||||
|
WarehouseLoadingsController,
|
||||||
|
WarehouseInspectionController,
|
||||||
|
WarehouseRulesController,
|
||||||
|
WarehouseInvoiceController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
WarehousesRepository,
|
||||||
|
WarehouseYardsRepository,
|
||||||
|
WarehouseZonesRepository,
|
||||||
|
WarehouseInventoryRepository,
|
||||||
|
WarehouseInventoryMovementRepository,
|
||||||
|
WarehouseActivityLogRepository,
|
||||||
|
WarehouseLoadingRepository,
|
||||||
|
WarehouseInspectionRepository,
|
||||||
|
WarehouseAllocationRuleRepository,
|
||||||
|
WarehouseFeeRuleRepository,
|
||||||
|
WarehouseFeeInvoiceRepository,
|
||||||
|
WarehouseFeeInvoiceItemRepository,
|
||||||
|
WarehousesService,
|
||||||
|
WarehouseYardsService,
|
||||||
|
WarehouseZonesService,
|
||||||
|
WarehouseInventoryService,
|
||||||
|
WarehouseActivityLogService,
|
||||||
|
WarehouseDashboardService,
|
||||||
|
WarehouseInspectionService,
|
||||||
|
WarehouseAllocationService,
|
||||||
|
WarehouseFeeService,
|
||||||
|
WarehouseInvoiceService,
|
||||||
|
WarehouseSchedulingAdapterService,
|
||||||
|
SchedulingReadFacade,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
WarehousesService,
|
||||||
|
WarehouseYardsService,
|
||||||
|
WarehouseZonesService,
|
||||||
|
WarehouseInventoryService,
|
||||||
|
WarehouseAllocationService,
|
||||||
|
WarehouseFeeService,
|
||||||
|
WarehouseSchedulingAdapterService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class WarehousesModule {}
|
||||||
@@ -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 { Warehouse } from './entities/warehouse.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehousesRepository extends BaseRepository<Warehouse> {
|
||||||
|
constructor(@InjectRepository(Warehouse) repository: Repository<Warehouse>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { FindManyOptions, ILike } from 'typeorm';
|
||||||
|
|
||||||
|
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||||
|
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||||
|
import { UpdateWarehouseDto } from './dto/update-warehouse.dto';
|
||||||
|
import { Warehouse } from './entities/warehouse.entity';
|
||||||
|
import { WarehousesRepository } from './warehouses.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WarehousesService {
|
||||||
|
constructor(private readonly warehousesRepository: WarehousesRepository) {}
|
||||||
|
|
||||||
|
async findAll(filter: FilterWarehouseDto): Promise<Warehouse[]> {
|
||||||
|
const where: FindManyOptions<Warehouse>['where'] = {
|
||||||
|
...(filter.type ? { type: filter.type } : {}),
|
||||||
|
...(filter.stationId ? { stationId: filter.stationId } : {}),
|
||||||
|
...(filter.status ? { status: filter.status } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = filter.search?.trim();
|
||||||
|
const whereClauses = search
|
||||||
|
? [
|
||||||
|
{ ...where, name: ILike(`%${search}%`) },
|
||||||
|
{ ...where, code: ILike(`%${search}%`) },
|
||||||
|
{ ...where, locationName: ILike(`%${search}%`) },
|
||||||
|
]
|
||||||
|
: where;
|
||||||
|
|
||||||
|
return this.warehousesRepository.findAll({
|
||||||
|
where: whereClauses,
|
||||||
|
relations: { facility: true },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Warehouse> {
|
||||||
|
const warehouse = await this.warehousesRepository.findById(id, {
|
||||||
|
relations: { facility: true, yards: { zones: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!warehouse) {
|
||||||
|
throw new NotFoundException(`Warehouse ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warehouse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateWarehouseDto): Promise<Warehouse> {
|
||||||
|
await this.assertCodeUnique(dto.code.trim());
|
||||||
|
|
||||||
|
return this.warehousesRepository.create({
|
||||||
|
name: dto.name.trim(),
|
||||||
|
code: dto.code.trim(),
|
||||||
|
type: dto.type,
|
||||||
|
stationId: dto.stationId ?? null,
|
||||||
|
facilityId: dto.facilityId ?? null,
|
||||||
|
locationName: dto.locationName?.trim() ?? null,
|
||||||
|
capacityWeight: dto.capacityWeight ?? null,
|
||||||
|
capacityContainers: dto.capacityContainers ?? null,
|
||||||
|
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||||
|
maxVolume: dto.maxVolume ?? null,
|
||||||
|
currentWeight: 0,
|
||||||
|
currentContainers: 0,
|
||||||
|
currentVolume: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateWarehouseDto): Promise<Warehouse> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.code && dto.code.trim() !== existing.code) {
|
||||||
|
await this.assertCodeUnique(dto.code.trim(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = dto.status ?? existing.status;
|
||||||
|
|
||||||
|
const updated = await this.warehousesRepository.update(id, {
|
||||||
|
name: dto.name?.trim() ?? existing.name,
|
||||||
|
code: dto.code?.trim() ?? existing.code,
|
||||||
|
type: dto.type ?? existing.type,
|
||||||
|
stationId: dto.stationId ?? existing.stationId,
|
||||||
|
facilityId: dto.facilityId ?? existing.facilityId,
|
||||||
|
locationName: dto.locationName?.trim() ?? existing.locationName,
|
||||||
|
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||||
|
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||||
|
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||||
|
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||||
|
status,
|
||||||
|
isActive: status === 'ACTIVE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Warehouse ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCodeUnique(code: string, ignoreId?: string): Promise<void> {
|
||||||
|
const [existing] = await this.warehousesRepository.findAll({ where: { code } });
|
||||||
|
|
||||||
|
if (existing && existing.id !== ignoreId) {
|
||||||
|
throw new ConflictException(`Warehouse code ${code} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/edr-freight-api/src/scripts/seed-edr-wagons.ts
Normal file
47
apps/edr-freight-api/src/scripts/seed-edr-wagons.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { AppDataSource } from '../data-source';
|
||||||
|
import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
|
||||||
|
|
||||||
|
async function seedEdRWagons() {
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
|
||||||
|
const queryRunner = AppDataSource.createQueryRunner();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
await new SeedEdRWagonFleet1750400000000().up(queryRunner);
|
||||||
|
|
||||||
|
const [summary] = await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total,
|
||||||
|
COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
|
||||||
|
COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
|
||||||
|
COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
|
||||||
|
COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
|
||||||
|
COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
|
||||||
|
COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
|
||||||
|
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready,
|
||||||
|
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready,
|
||||||
|
COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready
|
||||||
|
FROM freight.wagons w
|
||||||
|
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||||
|
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940';
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
console.log('Seeded EDR wagon fleet:', summary);
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedEdRWagons().catch((error) => {
|
||||||
|
console.error('Failed to seed EDR wagon fleet:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
170
apps/edr-freight-api/src/seed/batch1-4-test-data.seeder.ts
Normal file
170
apps/edr-freight-api/src/seed/batch1-4-test-data.seeder.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { Facility } from '../modules/facilities/entities/facility.entity';
|
||||||
|
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||||
|
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||||
|
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||||
|
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test data seeder for Batch 1-4 warehouse system.
|
||||||
|
* Creates Indode facility with warehouses, yards, zones, and sample inventory
|
||||||
|
* in all status states (RECEIVED, STORED, RESERVED, READY_FOR_LOADING, LOADED, DISPATCHED).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class Batch14TestDataSeeder {
|
||||||
|
private readonly logger = new Logger(Batch14TestDataSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const facilityRepo = this.dataSource.getRepository(Facility);
|
||||||
|
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||||
|
const yardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||||
|
const zoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||||
|
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||||
|
|
||||||
|
// Check if facility already exists
|
||||||
|
const existingFacility = await facilityRepo.findOne({
|
||||||
|
where: { code: 'INDODE_TEST' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingFacility) {
|
||||||
|
this.logger.log('Batch 1-4 test data already seeded, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Facility
|
||||||
|
const facility = await facilityRepo.save(
|
||||||
|
facilityRepo.create({
|
||||||
|
code: 'INDODE_TEST',
|
||||||
|
name: 'Indode Test Facility',
|
||||||
|
facilityType: 'DRY_PORT',
|
||||||
|
facilityStatus: 'ACTIVE',
|
||||||
|
locationName: 'Indode',
|
||||||
|
country: 'Djibouti',
|
||||||
|
city: 'Djibouti',
|
||||||
|
isActive: true,
|
||||||
|
capacity: 100000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Created facility: ${facility.code}`);
|
||||||
|
|
||||||
|
// Create Warehouse
|
||||||
|
const warehouse = await warehouseRepo.save(
|
||||||
|
warehouseRepo.create({
|
||||||
|
code: 'TEST_WH_001',
|
||||||
|
name: 'Test Warehouse 1',
|
||||||
|
type: 'OPEN_WAREHOUSE',
|
||||||
|
locationName: 'Test Location',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isActive: true,
|
||||||
|
facilityId: facility.id,
|
||||||
|
capacityWeight: 50000,
|
||||||
|
capacityContainers: 500,
|
||||||
|
maxWeight: 50000,
|
||||||
|
maxVolume: 10000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Created warehouse: ${warehouse.code}`);
|
||||||
|
|
||||||
|
// Create Yards
|
||||||
|
const yard1 = await yardRepo.save(
|
||||||
|
yardRepo.create({
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
code: 'YARD_001',
|
||||||
|
name: 'Container Yard 1',
|
||||||
|
type: 'CONTAINER_YARD',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isActive: true,
|
||||||
|
capacityWeight: 25000,
|
||||||
|
capacityContainers: 250,
|
||||||
|
maxWeight: 25000,
|
||||||
|
maxVolume: 5000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const yard2 = await yardRepo.save(
|
||||||
|
yardRepo.create({
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
code: 'YARD_002',
|
||||||
|
name: 'Bulk Yard 1',
|
||||||
|
type: 'BULK_YARD',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isActive: true,
|
||||||
|
capacityWeight: 25000,
|
||||||
|
capacityContainers: 100,
|
||||||
|
maxWeight: 25000,
|
||||||
|
maxVolume: 5000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Created yards: ${yard1.code}, ${yard2.code}`);
|
||||||
|
|
||||||
|
// Create Zones
|
||||||
|
const zone1 = await zoneRepo.save(
|
||||||
|
zoneRepo.create({
|
||||||
|
yardId: yard1.id,
|
||||||
|
code: 'ZONE_001',
|
||||||
|
name: 'Container Zone A',
|
||||||
|
type: 'CONTAINER_ZONE',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isActive: true,
|
||||||
|
capacityWeight: 12500,
|
||||||
|
capacityContainers: 125,
|
||||||
|
maxWeight: 12500,
|
||||||
|
maxVolume: 2500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const zone2 = await zoneRepo.save(
|
||||||
|
zoneRepo.create({
|
||||||
|
yardId: yard2.id,
|
||||||
|
code: 'ZONE_002',
|
||||||
|
name: 'Bulk Zone A',
|
||||||
|
type: 'BULK_ZONE',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isActive: true,
|
||||||
|
capacityWeight: 12500,
|
||||||
|
capacityContainers: 50,
|
||||||
|
maxWeight: 12500,
|
||||||
|
maxVolume: 2500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Created zones: ${zone1.code}, ${zone2.code}`);
|
||||||
|
|
||||||
|
// Create inventory in all statuses for testing
|
||||||
|
const statuses = ['RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'] as const;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (let i = 0; i < statuses.length; i++) {
|
||||||
|
const status = statuses[i];
|
||||||
|
const zone = i < 3 ? zone1 : zone2;
|
||||||
|
|
||||||
|
await inventoryRepo.save(
|
||||||
|
inventoryRepo.create({
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
yardId: zone.yardId,
|
||||||
|
zoneId: zone.id,
|
||||||
|
status: status as any,
|
||||||
|
quantity: 100 + i * 10,
|
||||||
|
weight: 500 + i * 50,
|
||||||
|
volume: 100 + i * 10,
|
||||||
|
arrivedAt: new Date(now.getTime() - i * 3600000),
|
||||||
|
storedAt: status !== 'RECEIVED' ? new Date(now.getTime() - (i - 1) * 3600000) : null,
|
||||||
|
reservedAt: ['RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null,
|
||||||
|
readyForLoadingAt: ['READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null,
|
||||||
|
loadedAt: ['LOADED', 'DISPATCHED'].includes(status) ? new Date() : null,
|
||||||
|
dispatchedAt: status === 'DISPATCHED' ? new Date() : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log('Created 6 test inventory items in all statuses');
|
||||||
|
|
||||||
|
this.logger.log('✅ Batch 1-4 test data seeded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Batch 1-4 seeder failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
apps/edr-freight-api/src/seed/indode-facility.seeder.ts
Normal file
186
apps/edr-freight-api/src/seed/indode-facility.seeder.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { Facility } from '../modules/facilities/entities/facility.entity';
|
||||||
|
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||||
|
import { WarehouseYard, type WarehouseYardType } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||||
|
import { WarehouseZone, type WarehouseZoneType } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||||
|
|
||||||
|
const INDODE_FACILITY = {
|
||||||
|
code: 'INDODE_DRY_PORT',
|
||||||
|
name: 'Indode Multipurpose Dry Port',
|
||||||
|
description: 'Main facility for container and cargo handling',
|
||||||
|
facilityType: 'DRY_PORT' as const,
|
||||||
|
facilityStatus: 'ACTIVE' as const,
|
||||||
|
locationName: 'Indode',
|
||||||
|
country: 'Djibouti',
|
||||||
|
city: 'Djibouti',
|
||||||
|
address: 'Indode, Djibouti',
|
||||||
|
latitude: 11.5447,
|
||||||
|
longitude: 43.145,
|
||||||
|
capacity: 50000,
|
||||||
|
isActive: true,
|
||||||
|
notes: 'Primary dry port for container consolidation and distribution',
|
||||||
|
};
|
||||||
|
|
||||||
|
const WAREHOUSES = [
|
||||||
|
{
|
||||||
|
name: 'Open Warehouse - Indode',
|
||||||
|
code: 'INDODE_OPEN',
|
||||||
|
type: 'OPEN_WAREHOUSE' as const,
|
||||||
|
locationName: 'Indode Open',
|
||||||
|
capacityWeight: 25000,
|
||||||
|
capacityContainers: 500,
|
||||||
|
maxWeight: 25000,
|
||||||
|
maxVolume: 5000,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Closed Warehouse - Indode',
|
||||||
|
code: 'INDODE_CLOSED',
|
||||||
|
type: 'CLOSED_WAREHOUSE' as const,
|
||||||
|
locationName: 'Indode Closed',
|
||||||
|
capacityWeight: 20000,
|
||||||
|
capacityContainers: 400,
|
||||||
|
maxWeight: 20000,
|
||||||
|
maxVolume: 4000,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const YARD_TYPES = [
|
||||||
|
'CONTAINER_YARD',
|
||||||
|
'BULK_YARD',
|
||||||
|
'GENERAL_CARGO_YARD',
|
||||||
|
'HAZARDOUS_YARD',
|
||||||
|
'COLD_STORAGE_YARD',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IndodeFacilitySeeder {
|
||||||
|
private readonly logger = new Logger(IndodeFacilitySeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const facilityRepo = manager.getRepository(Facility);
|
||||||
|
const warehouseRepo = manager.getRepository(Warehouse);
|
||||||
|
const yardRepo = manager.getRepository(WarehouseYard);
|
||||||
|
const zoneRepo = manager.getRepository(WarehouseZone);
|
||||||
|
|
||||||
|
// Ensure facility exists
|
||||||
|
const facility = await facilityRepo.findOne({
|
||||||
|
where: { code: INDODE_FACILITY.code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (facility) {
|
||||||
|
this.logger.log('Indode facility already exists, skipping seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFacility = facilityRepo.create(INDODE_FACILITY);
|
||||||
|
const savedFacility = await facilityRepo.save(newFacility);
|
||||||
|
this.logger.log(`Created facility: ${savedFacility.code}`);
|
||||||
|
|
||||||
|
// Create warehouses for the facility
|
||||||
|
for (const warehouseData of WAREHOUSES) {
|
||||||
|
try {
|
||||||
|
const warehouse = await warehouseRepo.findOne({
|
||||||
|
where: { code: warehouseData.code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (warehouse) {
|
||||||
|
this.logger.log(`Warehouse ${warehouseData.code} already exists, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWarehouse = warehouseRepo.create({
|
||||||
|
...warehouseData,
|
||||||
|
facilityId: savedFacility.id,
|
||||||
|
});
|
||||||
|
const savedWarehouse = await warehouseRepo.save(newWarehouse);
|
||||||
|
this.logger.log(`Created warehouse: ${savedWarehouse.code} under facility ${savedFacility.code}`);
|
||||||
|
|
||||||
|
// Create 11 yards per warehouse
|
||||||
|
await this.createYardsForWarehouse(yardRepo, zoneRepo, savedWarehouse);
|
||||||
|
} catch (warehouseError) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to create warehouse ${warehouseData.code}: ${warehouseError instanceof Error ? warehouseError.message : String(warehouseError)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
'Indode Multipurpose Dry Port facility seeded successfully with 2 warehouses and 11 yards each',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`IndodeFacilitySeeder error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createYardsForWarehouse(
|
||||||
|
yardRepo: any,
|
||||||
|
zoneRepo: any,
|
||||||
|
warehouse: Warehouse,
|
||||||
|
): Promise<void> {
|
||||||
|
const baseCapacityWeight = 5000;
|
||||||
|
const baseCapacityContainers = 100;
|
||||||
|
const yardCount = 11;
|
||||||
|
|
||||||
|
for (let i = 0; i < yardCount; i++) {
|
||||||
|
const yardType: WarehouseYardType = i < YARD_TYPES.length ? YARD_TYPES[i] : 'GENERAL_CARGO_YARD';
|
||||||
|
const yardCode = `${warehouse.code}_YARD_${String(i + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const existingYard = await yardRepo.findOne({ where: { code: yardCode } });
|
||||||
|
if (existingYard) {
|
||||||
|
this.logger.log(`Yard ${yardCode} already exists, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yardData = {
|
||||||
|
warehouseId: warehouse.id,
|
||||||
|
name: `${warehouse.code} ${yardType.replace(/_/g, ' ')} ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
code: yardCode,
|
||||||
|
type: yardType,
|
||||||
|
capacityWeight: baseCapacityWeight,
|
||||||
|
capacityContainers: baseCapacityContainers,
|
||||||
|
maxWeight: baseCapacityWeight,
|
||||||
|
maxVolume: baseCapacityWeight / 2,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newYard = yardRepo.create(yardData);
|
||||||
|
const savedYard = (await yardRepo.save(newYard)) as WarehouseYard;
|
||||||
|
this.logger.log(`Created yard: ${savedYard.code}`);
|
||||||
|
|
||||||
|
// Create default zone for the yard
|
||||||
|
const zoneType: WarehouseZoneType = yardType.replace('_YARD', '_ZONE') as WarehouseZoneType;
|
||||||
|
const zoneCode = `${savedYard.code}_ZONE_A`;
|
||||||
|
|
||||||
|
const zoneData = {
|
||||||
|
yardId: savedYard.id,
|
||||||
|
name: `${savedYard.name} Zone A`,
|
||||||
|
code: zoneCode,
|
||||||
|
type: zoneType,
|
||||||
|
capacityWeight: (baseCapacityWeight ?? 1000) / 2,
|
||||||
|
capacityContainers: (baseCapacityContainers ?? 100) / 2,
|
||||||
|
maxWeight: (baseCapacityWeight ?? 1000) / 2,
|
||||||
|
maxVolume: (baseCapacityWeight ?? 500) / 2,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newZone = zoneRepo.create(zoneData);
|
||||||
|
await zoneRepo.save(newZone);
|
||||||
|
this.logger.log(`Created zone: ${zoneCode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Network,
|
Network,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
PackageCheck,
|
||||||
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
Container,
|
Container,
|
||||||
Package,
|
Package,
|
||||||
|
PackageOpen,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
//TrainTrack,
|
//TrainTrack,
|
||||||
@@ -53,6 +56,17 @@ import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/perm
|
|||||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
|
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||||
|
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||||
|
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||||
|
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||||
|
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||||
|
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||||
|
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||||
|
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
|
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||||
|
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||||
|
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -143,6 +157,61 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
// },
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Warehouse Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Warehouse Dashboard",
|
||||||
|
href: "/dashboard/warehouse-dashboard",
|
||||||
|
icon: <LayoutDashboard />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Warehouses",
|
||||||
|
href: "/dashboard/warehouses",
|
||||||
|
icon: <Container />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Inventory",
|
||||||
|
href: "/dashboard/warehouse-inventory",
|
||||||
|
icon: <Package />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Arrival Queue",
|
||||||
|
href: "/dashboard/arrival-queue",
|
||||||
|
icon: <PackageOpen />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Loading Queue",
|
||||||
|
href: "/dashboard/loading-queue",
|
||||||
|
icon: <Truck />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Loaded Inventory",
|
||||||
|
href: "/dashboard/loaded-inventory",
|
||||||
|
icon: <PackageCheck />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dispatch Queue",
|
||||||
|
href: "/dashboard/dispatch-queue",
|
||||||
|
icon: <Send />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Inventory Inquiry",
|
||||||
|
href: "/dashboard/inventory-inquiry",
|
||||||
|
icon: <Boxes />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Allocation & Fees",
|
||||||
|
href: "/dashboard/warehouse-rules",
|
||||||
|
icon: <SlidersHorizontal />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Fee Invoices",
|
||||||
|
href: "/dashboard/warehouse-fee-invoices",
|
||||||
|
icon: <Wallet />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Administration",
|
title: "Administration",
|
||||||
items: [
|
items: [
|
||||||
@@ -303,6 +372,18 @@ const App = () => {
|
|||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
|
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||||
|
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||||
|
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||||
|
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
|
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||||
|
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||||
|
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||||
|
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling"
|
path="operations/train-scheduling"
|
||||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide error boundary. Without this, any render-time exception unmounts the
|
||||||
|
* React tree and the user sees a blank white screen. This surfaces the actual
|
||||||
|
* error message + stack so failures are diagnosable in place.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
state: ErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => this.setState({ error: null });
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error } = this.state;
|
||||||
|
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
background: "#f8fafc",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 720,
|
||||||
|
width: "100%",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #fecaca",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0, color: "#b91c1c", fontSize: 18 }}>Something went wrong</h2>
|
||||||
|
<p style={{ color: "#64748b", fontSize: 14 }}>
|
||||||
|
A render error was caught. Details below — share this with the developer.
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
background: "#0f172a",
|
||||||
|
color: "#fca5a5",
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
{"\n\n"}
|
||||||
|
{error.stack}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "8px 16px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#0f766e",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user