Merge branch 'freight_feature/priority' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
Marshal
2026-06-18 14:13:50 +00:00
193 changed files with 14621 additions and 460 deletions

View File

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

View File

@@ -44,9 +44,10 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -55,7 +56,10 @@ import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
@Module({
imports: [
@@ -112,17 +116,21 @@ import { OverviewModule } from './modules/overview/overview.module';
ContainersModule,
CargoesModule,
RoutesModule,
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,
],
providers: [
EdrOrgSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
DemoBookingsSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,9 @@ export const BOOKING_STATUSES = [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
'APPROVED',
'READY_FOR_ASSIGNMENT',
'WAGON_ASSIGNED',
'INVOICED',
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString, IsUUID } from 'class-validator';
export class UnloadBookingDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
unloadedAt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(' → '),
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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