mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -69,8 +69,8 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ coverage/
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||
TELEBIRR_PRIVATE_KEY=
|
||||
TELEBIRR_PUBLIC_KEY=
|
||||
TELEBIRR_INSECURE_TLS=false
|
||||
|
||||
# Portal pages the payment provider redirects the browser to after payment.
|
||||
# Point these at the freight portal's public payment result routes.
|
||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
|
||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||
JWT_SECRET=
|
||||
JWT_ACCESS_TOKEN_SECRET=
|
||||
|
||||
@@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||
import { CustomersModule } from "./modules/customers/customers.module";
|
||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { OtpModule } from './modules/otp/otp.module';
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||
@@ -44,11 +43,14 @@ 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 { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.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
|
||||
@@ -59,9 +61,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
import { RoutesModule } from './modules/routes/routes.module';
|
||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
import { DriversModule } from './modules/drivers/drivers.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -99,7 +99,6 @@ import { DriversModule } from './modules/drivers/drivers.module';
|
||||
TrainSchedulesModule,
|
||||
TrainSchedulingModule,
|
||||
SchedulingRescheduleModule,
|
||||
CustomersModule,
|
||||
CompaniesModule,
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
@@ -120,21 +119,22 @@ import { DriversModule } from './modules/drivers/drivers.module';
|
||||
RoutesModule,
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
FacilitiesModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
DemoBookingsSeeder,
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -143,11 +143,14 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly demoBookingsSeeder: DemoBookingsSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -158,13 +161,21 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.demoBookingsSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
await this.indodeFacilitySeeder.run();
|
||||
await this.batch14TestDataSeeder.run();
|
||||
await this.batch5TestDataSeeder.run();
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
||||
// registered as providers but are not run. Re-inject + call .run() to enable.
|
||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ export function deriveTradeDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
const originCountry = originYard.country?.trim().toLowerCase();
|
||||
const destinationCountry = destinationYard.country?.trim().toLowerCase();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
if (originCountry === 'djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
|
||||
@@ -114,7 +114,9 @@ export class ContractViewModelBuilder {
|
||||
tinNumber: this.valueOrDash(booking.company?.tin),
|
||||
vatNumber: this.valueOrDash(booking.company?.vatNumber),
|
||||
fanNumber: this.valueOrDash(booking.company?.fanNumber),
|
||||
businessLicense: this.valueOrDash(booking.company?.businessLicense),
|
||||
businessLicense: this.valueOrDash(
|
||||
booking.company?.companyProfiles?.[0]?.businessLicense,
|
||||
),
|
||||
},
|
||||
provider: {
|
||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableIndex,
|
||||
TableForeignKey,
|
||||
} from "typeorm";
|
||||
|
||||
export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
|
||||
name = "CreateCompanyProfiles1752000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: "freight",
|
||||
name: "company_profiles",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
generationStrategy: "uuid",
|
||||
default: "gen_random_uuid()",
|
||||
},
|
||||
{ name: "company_id", type: "uuid" },
|
||||
{ name: "type", type: "varchar", length: "32" },
|
||||
{ name: "reference", type: "varchar", length: "20", isUnique: true },
|
||||
{
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: "32",
|
||||
default: "'active'",
|
||||
},
|
||||
{
|
||||
name: "business_license",
|
||||
type: "varchar",
|
||||
length: "100",
|
||||
isNullable: true,
|
||||
},
|
||||
{ name: "attributes", type: "jsonb", isNullable: true },
|
||||
{ name: "created_at", type: "timestamptz", default: "now()" },
|
||||
{ name: "updated_at", type: "timestamptz", default: "now()" },
|
||||
{ name: "deleted_at", type: "timestamptz", isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
"freight.company_profiles",
|
||||
new TableForeignKey({
|
||||
columnNames: ["company_id"],
|
||||
referencedTableName: "companies",
|
||||
referencedSchema: "freight",
|
||||
referencedColumnNames: ["id"],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
"freight.company_profiles",
|
||||
new TableIndex({ columnNames: ["company_id"] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.company_profiles",
|
||||
new TableIndex({ columnNames: ["type"] }),
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable("freight.company_profiles");
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MoveBusinessLicenseToProfile1752000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'MoveBusinessLicenseToProfile1752000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.company_profiles cp
|
||||
SET business_license = c.business_license
|
||||
FROM freight.companies c
|
||||
WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies c
|
||||
SET business_license = cp.business_license
|
||||
FROM (
|
||||
SELECT DISTINCT ON (cp2.company_id)
|
||||
cp2.company_id, cp2.business_license
|
||||
FROM freight.company_profiles cp2
|
||||
WHERE cp2.business_license IS NOT NULL
|
||||
ORDER BY cp2.company_id, cp2.created_at
|
||||
) cp
|
||||
WHERE cp.company_id = c.id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Full wagon re-seed — runs in this order:
|
||||
*
|
||||
* 1. DELETE all existing wagons (hard delete, not soft).
|
||||
* 2. UPSERT all 10 standard wagon types so they are guaranteed to exist.
|
||||
* 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across
|
||||
* the 5 main operational yards (10 wagons per yard per type):
|
||||
*
|
||||
* KALITY — Kality Rail Terminal
|
||||
* MOJO — Mojo Dry Port
|
||||
* DIRE_DAWA — Dire Dawa Yard
|
||||
* DJIB_PORT — Djibouti Port Terminal
|
||||
* NAGAD — Nagad Terminal, Djibouti
|
||||
*
|
||||
* Wagon numbers follow the pattern <TYPE_CODE>-NNNN (e.g. NW5-0001 … NW5-0050).
|
||||
* Yard IDs are fetched live from freight.yards so the migration is safe across
|
||||
* all environments regardless of UUID values.
|
||||
*/
|
||||
export class SeedWagonsWithYardAssignment1784000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'SeedWagonsWithYardAssignment1784000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── STEP 1: Remove all wagons ──────────────────────────────────────────
|
||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||
|
||||
// ── STEP 2: Ensure all 10 wagon types exist ────────────────────────────
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.wagon_types (
|
||||
code,
|
||||
name,
|
||||
capacity_tons,
|
||||
length_meters,
|
||||
max_wagons_per_train,
|
||||
supported_load_types,
|
||||
is_active,
|
||||
tare_weight_tons
|
||||
)
|
||||
VALUES
|
||||
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0),
|
||||
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0),
|
||||
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0),
|
||||
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0),
|
||||
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0),
|
||||
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0),
|
||||
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0),
|
||||
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0),
|
||||
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0),
|
||||
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
capacity_tons = EXCLUDED.capacity_tons,
|
||||
length_meters = EXCLUDED.length_meters,
|
||||
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||
supported_load_types = EXCLUDED.supported_load_types,
|
||||
is_active = true,
|
||||
tare_weight_tons = EXCLUDED.tare_weight_tons,
|
||||
deleted_at = NULL,
|
||||
updated_at = now();
|
||||
`);
|
||||
|
||||
// ── STEP 3: Seed 50 wagons per type across 5 yards ────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
wt RECORD;
|
||||
yard_kality UUID;
|
||||
yard_mojo UUID;
|
||||
yard_dire_dawa UUID;
|
||||
yard_djib_port UUID;
|
||||
yard_nagad UUID;
|
||||
yards UUID[];
|
||||
i INT;
|
||||
yard_id UUID;
|
||||
wagon_num TEXT;
|
||||
v_tare NUMERIC;
|
||||
v_payload NUMERIC;
|
||||
BEGIN
|
||||
-- Fetch yard IDs by code (safe across envs — UUIDs differ per DB)
|
||||
SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1;
|
||||
SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1;
|
||||
SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1;
|
||||
SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1;
|
||||
SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1;
|
||||
|
||||
IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL
|
||||
OR yard_djib_port IS NULL OR yard_nagad IS NULL
|
||||
THEN
|
||||
RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.';
|
||||
END IF;
|
||||
|
||||
yards := ARRAY[
|
||||
yard_kality,
|
||||
yard_mojo,
|
||||
yard_dire_dawa,
|
||||
yard_djib_port,
|
||||
yard_nagad
|
||||
];
|
||||
|
||||
FOR wt IN
|
||||
SELECT id, code, capacity_tons, tare_weight_tons
|
||||
FROM freight.wagon_types
|
||||
WHERE is_active = true
|
||||
ORDER BY code
|
||||
LOOP
|
||||
v_tare := COALESCE(wt.tare_weight_tons, 20.0);
|
||||
v_payload := COALESCE(wt.capacity_tons, 60.0);
|
||||
|
||||
FOR i IN 1 .. 50 LOOP
|
||||
wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0');
|
||||
yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K …
|
||||
|
||||
INSERT INTO freight.wagons (
|
||||
id,
|
||||
wagon_number,
|
||||
wagon_type_id,
|
||||
tare_weight,
|
||||
max_payload_weight,
|
||||
status,
|
||||
current_yard_id,
|
||||
train_id,
|
||||
sequence_number,
|
||||
notes,
|
||||
train_set_wagon_id,
|
||||
current_train_schedule_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
uuid_generate_v4(),
|
||||
wagon_num,
|
||||
wt.id,
|
||||
v_tare,
|
||||
v_payload,
|
||||
'Available',
|
||||
yard_id,
|
||||
NULL, NULL, NULL, NULL, NULL,
|
||||
now(), now()
|
||||
)
|
||||
ON CONFLICT (wagon_number) DO NOTHING;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code;
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Remove all seeded wagons (full wipe — mirrors what up() did)
|
||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Day-level booking pool: customers select a DAY (route + day), not a specific
|
||||
* train. The batch engine's pool query filters bookings on
|
||||
* (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
|
||||
* index backs that scan.
|
||||
*/
|
||||
export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_route_day
|
||||
ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 5 — warehouse allocation rules, storage/demurrage fee rules,
|
||||
* and demurrage lifecycle timestamps on inventory.
|
||||
* and demurrage lifecycle timestamps on inventory. Idempotent.
|
||||
*/
|
||||
export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface {
|
||||
export class AddWarehouseAllocationAndFeeRules1791000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
|
||||
|
||||
/** Batch 6 — warehouse fee invoices + invoice items. */
|
||||
export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface {
|
||||
/** Batch 6 — warehouse fee invoices + invoice items. Idempotent (createTable ifNotExists). */
|
||||
export class AddWarehouseFeeInvoices1791000000001 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Import pickup branch on warehouse_inventory:
|
||||
* - release_order_reference: DO / release order number sent to the customer
|
||||
* - delivered_at: when the goods were handed over (proof of delivery)
|
||||
*
|
||||
* Idempotent: the shared dev DB may already carry some of these columns
|
||||
* (added by another checkout), so only add what is missing.
|
||||
*/
|
||||
export class AddImportPickupDeliveryColumns1791000000002 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'release_order_reference'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'release_order_reference', type: 'varchar', length: '100', isNullable: true }),
|
||||
);
|
||||
}
|
||||
if (!(await queryRunner.hasColumn(this.table, 'delivered_at'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'delivered_at', type: 'timestamptz', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn(this.table, 'release_order_reference')) {
|
||||
await queryRunner.dropColumn(this.table, 'release_order_reference');
|
||||
}
|
||||
if (await queryRunner.hasColumn(this.table, 'delivered_at')) {
|
||||
await queryRunner.dropColumn(this.table, 'delivered_at');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 8 — train-arrival unload landing state on warehouse_inventory:
|
||||
* - unloaded_at: when the goods were unloaded off the arrived train (before storage/inspection)
|
||||
*
|
||||
* The `status` column is a free varchar, so the new 'UNLOADED' value needs no schema change.
|
||||
* Idempotent: the shared dev DB may already carry this column (added by another checkout).
|
||||
*/
|
||||
export class AddInventoryUnloadedAt1791000000003 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'unloaded_at'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'unloaded_at', type: 'timestamptz', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn(this.table, 'unloaded_at')) {
|
||||
await queryRunner.dropColumn(this.table, 'unloaded_at');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
@@ -69,7 +68,12 @@ export class BookingTransitionService {
|
||||
priorityScore,
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
|
||||
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
|
||||
// arrives. The returned status reflects that outcome.
|
||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||
updated!.id,
|
||||
);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
@@ -143,7 +147,10 @@ export class BookingTransitionService {
|
||||
},
|
||||
} as never);
|
||||
|
||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
||||
// Same consolidation treatment as the direct submit path.
|
||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||
updated!.id,
|
||||
);
|
||||
return {
|
||||
bookingId: finalBooking.id,
|
||||
status: finalBooking.status,
|
||||
@@ -188,18 +195,11 @@ export class BookingTransitionService {
|
||||
|
||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
// Consolidation gate: a booking whose containers don't fill whole wagons
|
||||
// cannot be accepted until it is paired with a complementary booking.
|
||||
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
||||
if (gate.blocked) {
|
||||
throw new ConflictException(
|
||||
gate.message ??
|
||||
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
@@ -117,9 +118,23 @@ export class BookingsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
findAll(@Query() filter: FilterBookingDto) {
|
||||
async findAll(
|
||||
@Query() filter: FilterBookingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Staff (backoffice) see every booking. Customers (portal) are always
|
||||
// force-scoped to their own company, regardless of any companyId they pass.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
const companyId =
|
||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||
if (!companyId) return { items: [], total: 0 };
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@@ -166,18 +181,60 @@ export class BookingsController {
|
||||
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
async findByReference(@Param('reference') reference: string) {
|
||||
async findByReference(
|
||||
@Param('reference') reference: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: 'Shipment tracking timeline for a booking',
|
||||
description:
|
||||
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||||
'tracking events. Scoped to the customer\'s own company.',
|
||||
})
|
||||
async findTracking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.bookingsService.getBookingTracking(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
|
||||
@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
// Only pair bookings the customer has committed (SUBMITTED) or that are
|
||||
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
|
||||
// pairing never prematurely submits an unfinished/unpriced draft.
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
@@ -686,6 +689,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: string;
|
||||
trainScheduleId?: string;
|
||||
/**
|
||||
* EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
|
||||
* the whole (route, day) pool rather than bookings pre-targeted to one train.
|
||||
*/
|
||||
day?: string;
|
||||
}): Promise<Booking[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
@@ -703,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
|
||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
||||
if (options.trainScheduleId) {
|
||||
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
|
||||
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
|
||||
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
|
||||
// booking that still carries train_schedule_id).
|
||||
if (options.day) {
|
||||
qb.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day: options.day },
|
||||
);
|
||||
} else if (options.trainScheduleId) {
|
||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||
trainScheduleId: options.trainScheduleId,
|
||||
});
|
||||
@@ -762,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level batch pool: ready, not-yet-allocated bookings on a route for one
|
||||
* EAT calendar day, regardless of which train they end up on. Same status
|
||||
* rules and ordering as {@link findBatchPool}, but keyed on
|
||||
* (origin, destination, day) instead of train_schedule_id — the engine then
|
||||
* distributes these across all trains departing that day.
|
||||
*/
|
||||
findBatchPoolByRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
||||
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||
destinationYardId,
|
||||
})
|
||||
.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -52,6 +57,8 @@ export class BookingsService {
|
||||
private readonly minioService: MinioService,
|
||||
// private readonly customersService: CustomersService,
|
||||
private readonly companiesService: CompaniesService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
@@ -146,13 +153,17 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Enable consolidation when any container line leaves a wagon partially filled
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
|
||||
*
|
||||
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
|
||||
* half-empty wagon, so `explicit === false` is ignored when consolidation is
|
||||
* actually needed. The opt-in flag only matters for cargo that already fills
|
||||
* whole wagons (where consolidation is moot anyway).
|
||||
*/
|
||||
private async resolveConsolidation(
|
||||
containers: CreateBookingContainerDto[],
|
||||
explicit?: boolean,
|
||||
): Promise<boolean> {
|
||||
if (explicit === false) return false;
|
||||
const needs = await this.consolidationService.needsConsolidation(
|
||||
containers.map((c) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
@@ -193,10 +204,11 @@ export class BookingsService {
|
||||
return { booking: paired, messages };
|
||||
}
|
||||
|
||||
if (booking.status === 'DRAFT') {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
// No partner yet — park the booking so it waits. Applies both pre-submit
|
||||
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
|
||||
// reach this method.
|
||||
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
}
|
||||
|
||||
const pending = await this.findById(booking.id);
|
||||
@@ -205,45 +217,24 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
||||
* paired) booking plus whether it still needs a consolidation partner.
|
||||
* When a booking needs consolidation and none is found, it is parked in
|
||||
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
||||
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
|
||||
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
|
||||
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
|
||||
* waits for a later complementary booking to complete the wagon.
|
||||
*
|
||||
* Returns the re-fetched booking, so callers can reflect the resulting status
|
||||
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
|
||||
*/
|
||||
async resolveConsolidationGate(bookingId: string): Promise<{
|
||||
booking: Booking;
|
||||
blocked: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
let booking = await this.findById(bookingId);
|
||||
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
// Already paired — passes the gate.
|
||||
// Already paired (e.g. a partner submitted first) — nothing to do.
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false };
|
||||
return booking;
|
||||
}
|
||||
|
||||
const needs =
|
||||
await this.consolidationService.needsConsolidationFromBooking(booking);
|
||||
if (!needs) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
// A partner may have appeared since submission — try to pair now.
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
booking = result.booking;
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false, message: result.messages.join(' ') };
|
||||
}
|
||||
|
||||
// Still no partner — park it and block the accept.
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
booking = await this.findById(booking.id);
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
return {
|
||||
booking,
|
||||
blocked: true,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
return result.booking;
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
@@ -283,8 +274,8 @@ export class BookingsService {
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
||||
if (dto.trainScheduleId) {
|
||||
// Staff manual pin: the schedule must be OPEN and on the same route.
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: dto.trainScheduleId } });
|
||||
@@ -300,6 +291,22 @@ export class BookingsService {
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
} else {
|
||||
// Day-level pool: the customer picked a DAY — require that the route has at
|
||||
// least one OPEN departure on that EAT day. The batch engine assigns the
|
||||
// train later.
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
day,
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
@@ -575,6 +582,7 @@ export class BookingsService {
|
||||
/** Return a paginated list of bookings matching the filter. */
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
@@ -587,7 +595,9 @@ export class BookingsService {
|
||||
...statusFilter,
|
||||
...schedulingStatusFilter,
|
||||
assignedToSchedule: filter.assignedToSchedule,
|
||||
companyId: filter.companyId,
|
||||
// A forced company scope (portal/customer) overrides any caller-provided
|
||||
// companyId so a customer can only ever see their own company's bookings.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
@@ -631,6 +641,109 @@ export class BookingsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company a customer user belongs to, for scoping their own
|
||||
* bookings. Returns null when no profile/company is linked yet.
|
||||
*/
|
||||
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
return company?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||
* to the company the authenticated user is linked to — otherwise it is hidden
|
||||
* behind a NotFound so booking IDs can't be probed.
|
||||
*/
|
||||
async assertCustomerCanAccessBooking(
|
||||
userId: string | undefined,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new ForbiddenException('Authentication required');
|
||||
}
|
||||
const companyId = await this.resolveCustomerCompanyId(userId);
|
||||
if (!companyId || booking.companyId !== companyId) {
|
||||
// Don't reveal that the booking exists for another company.
|
||||
throw new NotFoundException(`Booking ${booking.id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer-facing shipment tracking payload for a booking from the
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
* responsible for authorizing access to the booking first.
|
||||
*
|
||||
* When the booking has not been assigned to a train yet, returns a valid
|
||||
* "no schedule" payload so the UI can show a pre-dispatch state.
|
||||
*/
|
||||
async getBookingTracking(
|
||||
bookingId: string,
|
||||
): Promise<Freight.IBookingTracking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
const empty: Freight.IBookingTracking = {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
hasSchedule: false,
|
||||
scheduleId: null,
|
||||
trainNumber: null,
|
||||
scheduleStatus: null,
|
||||
direction: null,
|
||||
origin: null,
|
||||
destination: null,
|
||||
stations: [],
|
||||
checkpoints: [],
|
||||
currentSequenceNo: -1,
|
||||
actualDepartureAt: null,
|
||||
actualArrivalAt: null,
|
||||
scheduledDepartureAt: null,
|
||||
scheduledArrivalAt: null,
|
||||
};
|
||||
|
||||
if (!booking.trainScheduleId) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// Pull the live corridor + checkpoints for the assigned schedule. If the
|
||||
// schedule was removed, fall back to the pre-dispatch state rather than 500.
|
||||
let track: Awaited<
|
||||
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
|
||||
>;
|
||||
try {
|
||||
track = await this.trainSchedulingService.getScheduleCheckpoints(
|
||||
booking.trainScheduleId,
|
||||
);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
hasSchedule: true,
|
||||
scheduleId: track.scheduleId,
|
||||
trainNumber: track.trainNumber,
|
||||
scheduleStatus: track.status as Freight.TrainScheduleStatus,
|
||||
direction: track.direction,
|
||||
origin: track.origin,
|
||||
destination: track.destination,
|
||||
stations: track.stations,
|
||||
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
|
||||
currentSequenceNo: track.currentSequenceNo,
|
||||
actualDepartureAt: track.actualDepartureAt,
|
||||
actualArrivalAt: track.actualArrivalAt,
|
||||
scheduledDepartureAt: track.scheduledDepartureAt,
|
||||
scheduledArrivalAt: track.scheduledArrivalAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||
const page = filter.page ?? 1;
|
||||
|
||||
@@ -91,12 +91,21 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
||||
/**
|
||||
* Staff-only manual pin to a specific train. Customers omit this — they pick a
|
||||
* DAY via {@link scheduledDate} and the batch engine assigns a train within
|
||||
* that (route, day) pool. When provided, the schedule must be OPEN and on the
|
||||
* booking route.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
/** The day the customer wants to ship (the pool day key). */
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@@ -280,7 +280,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
||||
/**
|
||||
* The train this booking is assigned to. FK to train_schedules.
|
||||
*
|
||||
* Day-level pooling: customers no longer pick a train — they pick a DAY, and
|
||||
* this stays null at creation. The batch engine sets it when it assigns the
|
||||
* booking to a specific train within its (route, day) pool; staff may also
|
||||
* pin it manually. The day-level pool is keyed on
|
||||
* (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
|
||||
*/
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { FreightAdmin } from '../../common/booking-guards';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { ResponseCompanyDto } from './dto/response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
||||
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
} from "./dto/response-company.dto";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
@@ -25,36 +41,47 @@ interface CurrentIamUser {
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
@ApiTags('Companies')
|
||||
@Controller('companies')
|
||||
@ApiTags("Companies")
|
||||
@Controller("companies")
|
||||
export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
) { }
|
||||
|
||||
@Get('getInfo')
|
||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
||||
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
@Get("getInfo")
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
|
||||
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
@Get("profile")
|
||||
@ApiOperation({ summary: "Get flattened profile for the settings page" })
|
||||
async getProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new ProfileResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
||||
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
||||
@Get("dashboard")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
|
||||
})
|
||||
async getDashboard(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<DashboardSummaryResponseDto> {
|
||||
return this.companiesService.getDashboardSummary(user.id);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||
@Patch("profile")
|
||||
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
||||
async updateProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
@@ -62,145 +89,153 @@ export class CompaniesController {
|
||||
return this.companiesService.updateProfile(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
||||
@Post("company-profiles")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
|
||||
})
|
||||
async addCompanyProfiles(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: AddCompanyProfilesDto,
|
||||
): Promise<ResponseCompanyProfileDto[]> {
|
||||
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
||||
user.id,
|
||||
dto.types,
|
||||
);
|
||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||
}
|
||||
|
||||
// Used by portal
|
||||
@Post("create")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a company with its associated external profile (onboarding)",
|
||||
})
|
||||
async createWithProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CreateCompanyWithProfileDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const nameParts = (user.name?.en ?? '').split(' ');
|
||||
const { profile, company } = await this.companiesService.createCompanyWithProfile(
|
||||
const nameParts = (user.name?.en ?? "").split(" ");
|
||||
const { profile, company } =
|
||||
await this.companiesService.createCompanyWithProfile(
|
||||
{
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || '',
|
||||
lastName: nameParts.slice(-1)[0] || '',
|
||||
email: user.email ?? '',
|
||||
phone: user.phoneNumber ?? '',
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(-1)[0] || "",
|
||||
email: user.email ?? "",
|
||||
phone: user.phoneNumber ?? "",
|
||||
},
|
||||
dto,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
// Used by backoffice
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
||||
})
|
||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.createCompany(dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all companies' })
|
||||
@ApiOperation({ summary: "List all companies" })
|
||||
async findAll(): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get('type/:type')
|
||||
@ApiOperation({ summary: 'Find companies by type' })
|
||||
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
|
||||
@Get("type/:type")
|
||||
@ApiOperation({ summary: "Find companies by type" })
|
||||
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
|
||||
return companies
|
||||
.filter((c) => c.type === type)
|
||||
.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Search companies by name' })
|
||||
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
|
||||
@Get("search")
|
||||
@ApiOperation({ summary: "Search companies by name" })
|
||||
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies
|
||||
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
||||
.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get company by ID' })
|
||||
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get company by ID" })
|
||||
async findById(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.findCompanyById(id);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Update a company' })
|
||||
@ApiOperation({ summary: "Update a company" })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.updateCompany(id, dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||
@ApiOperation({ summary: "Soft-delete a company" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.companiesService.deleteCompany(id);
|
||||
}
|
||||
|
||||
@Post(':companyId/documents')
|
||||
@Post(":companyId/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
||||
async uploadDocuments(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
) {
|
||||
return this.filesService.uploadMany(companyId, 'companies', files);
|
||||
return this.filesService.uploadMany(companyId, "companies", files);
|
||||
}
|
||||
|
||||
@Post(':companyId/profiles')
|
||||
@Post(":companyId/profiles")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
async createProfile(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@Body() dto: CreateExternalProfileDto,
|
||||
): Promise<ResponseExternalProfileDto> {
|
||||
const profile = await this.companiesService.createProfile({ ...dto, companyId });
|
||||
const profile = await this.companiesService.createProfile({
|
||||
...dto,
|
||||
companyId,
|
||||
});
|
||||
return new ResponseExternalProfileDto(profile);
|
||||
}
|
||||
|
||||
@Get(':companyId/profiles')
|
||||
@ApiOperation({ summary: 'List profiles for a company' })
|
||||
@Get(":companyId/profiles")
|
||||
@ApiOperation({ summary: "List profiles for a company" })
|
||||
async listProfiles(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<ResponseExternalProfileDto[]> {
|
||||
const profiles = await this.companiesService.findProfilesByCompany(companyId);
|
||||
const profiles =
|
||||
await this.companiesService.findProfilesByCompany(companyId);
|
||||
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
||||
}
|
||||
|
||||
@Get('profile/user/:userId')
|
||||
@ApiOperation({ summary: 'Get profile by IAM user ID' })
|
||||
@Get("profile/user/:userId")
|
||||
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||
async findProfileByUser(
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
@Param("userId", ParseUUIDPipe) userId: string,
|
||||
): Promise<ResponseExternalProfileDto> {
|
||||
const profile = await this.companiesService.findProfileByUserId(userId);
|
||||
return new ResponseExternalProfileDto(profile);
|
||||
}
|
||||
|
||||
@Post('ff-clients')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||
const client = await this.companiesService.createFFClient(dto);
|
||||
return new ResponseFFClientDto(client);
|
||||
}
|
||||
|
||||
@Get(':forwarderCompanyId/clients')
|
||||
@ApiOperation({ summary: 'List clients of a forwarder' })
|
||||
async listFFClients(
|
||||
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
|
||||
): Promise<ResponseFFClientDto[]> {
|
||||
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
|
||||
return clients.map((c) => new ResponseFFClientDto(c));
|
||||
}
|
||||
|
||||
@Delete('ff-clients/:id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.companiesService.deleteFFClient(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { CompaniesController } from "./companies.controller";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||
import { Company } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
||||
providers: [
|
||||
CompaniesService,
|
||||
CompaniesRepository,
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyDashboardRepository,
|
||||
],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule { }
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
import { Company } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
CompanyProfile,
|
||||
ProfileType,
|
||||
ProfileStatus,
|
||||
} from "./entities/company-profile.entity";
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
@@ -27,8 +35,8 @@ export interface UserIdentity {
|
||||
export class CompaniesService {
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly ffClientsRepo: FFClientRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
) { }
|
||||
|
||||
@@ -40,27 +48,33 @@ export class CompaniesService {
|
||||
return this.companiesRepo.create(dto);
|
||||
}
|
||||
|
||||
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
|
||||
async createCompanyWithProfile(
|
||||
identity: UserIdentity,
|
||||
dto: CreateCompanyWithProfileDto,
|
||||
): Promise<{ company: Company; profile: ExternalProfile }> {
|
||||
if (dto.tin) {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
if (exists) {
|
||||
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
||||
throw new ConflictException(
|
||||
`Company with TIN ${dto.tin} already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
||||
if (existingProfile) {
|
||||
throw new ConflictException(`Profile with email ${identity.email} already exists`);
|
||||
throw new ConflictException(
|
||||
`Profile with email ${identity.email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: dto.companyName,
|
||||
type: dto.companyType,
|
||||
tin: dto.tin ?? '',
|
||||
tin: dto.tin ?? "",
|
||||
vatNumber: dto.vatNumber ?? null,
|
||||
businessLicense: dto.fanNumber ?? null,
|
||||
fanNumber: dto.fanNumber ?? null,
|
||||
country: dto.companyLocation ?? 'Ethiopia',
|
||||
country: dto.companyLocation ?? "Ethiopia",
|
||||
address: dto.companyAddress ?? null,
|
||||
phone: dto.companyPhone ?? null,
|
||||
email: dto.companyEmail ?? null,
|
||||
@@ -78,11 +92,39 @@ export class CompaniesService {
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
});
|
||||
|
||||
// Persist the operational role(s) chosen during onboarding. Types are
|
||||
// already constrained to the company type on the client; any that don't
|
||||
// match are skipped defensively rather than failing the whole signup.
|
||||
if (dto.companyProfiles?.length) {
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
for (const input of dto.companyProfiles) {
|
||||
if (!allowedTypes.includes(input.type)) continue;
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
company.id,
|
||||
input.type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const reference = await this.companyProfilesRepo.generateReference(
|
||||
input.type,
|
||||
);
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId: company.id,
|
||||
type: input.type,
|
||||
reference,
|
||||
businessLicense: input.businessLicense ?? null,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||
company.id,
|
||||
);
|
||||
}
|
||||
|
||||
return { company, profile };
|
||||
}
|
||||
|
||||
async findAllCompanies(): Promise<Company[]> {
|
||||
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
|
||||
return this.companiesRepo.findAll({ order: { name: "ASC" } });
|
||||
}
|
||||
|
||||
async findCompanyById(id: string): Promise<Company> {
|
||||
@@ -91,12 +133,21 @@ export class CompaniesService {
|
||||
return company;
|
||||
}
|
||||
|
||||
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
async getCompanyInfoByUserId(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const company = profile.company;
|
||||
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
|
||||
if (!company)
|
||||
throw new NotFoundException(
|
||||
`Company for profile ${profile.id} not found`,
|
||||
);
|
||||
|
||||
company.companyProfiles =
|
||||
await this.companyProfilesRepo.findByCompanyId(company.id);
|
||||
|
||||
return { profile, company };
|
||||
}
|
||||
@@ -114,7 +165,9 @@ export class CompaniesService {
|
||||
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||
* delivered/completed status.
|
||||
*/
|
||||
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
|
||||
async getDashboardSummary(
|
||||
userId: string,
|
||||
): Promise<DashboardSummaryResponseDto> {
|
||||
// A user without a company profile has no bookings — return an empty summary
|
||||
// rather than 404, so the portal home still renders.
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
@@ -125,7 +178,9 @@ export class CompaniesService {
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||
// Same point in the previous year, so YoY compares like-for-like windows.
|
||||
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
|
||||
const prevYearToDate = new Date(
|
||||
prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
|
||||
);
|
||||
|
||||
const [
|
||||
deliveredThis,
|
||||
@@ -139,20 +194,36 @@ export class CompaniesService {
|
||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(
|
||||
companyId,
|
||||
prevYearStart,
|
||||
prevYearToDate,
|
||||
),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
||||
this.dashboardRepo.sumCommittedTonnage(
|
||||
companyId,
|
||||
prevYearStart,
|
||||
prevYearToDate,
|
||||
),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(
|
||||
companyId,
|
||||
this.monthsAgo(now, 5),
|
||||
now,
|
||||
),
|
||||
]);
|
||||
|
||||
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
||||
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||
const spendPrev =
|
||||
spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||
|
||||
return {
|
||||
deliveredCount: deliveredThis,
|
||||
// Share of committed bookings that reached delivered/completed.
|
||||
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
|
||||
completionRate:
|
||||
committedThis > 0
|
||||
? Math.round((deliveredThis / committedThis) * 100)
|
||||
: 0,
|
||||
spendYtd: spend.total,
|
||||
spendCurrency: spend.currency,
|
||||
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||
@@ -172,12 +243,12 @@ export class CompaniesService {
|
||||
deliveredCount: 0,
|
||||
completionRate: 0,
|
||||
spendYtd: 0,
|
||||
spendCurrency: 'ETB',
|
||||
spendCurrency: "ETB",
|
||||
spendYtdChangePct: 0,
|
||||
freightVolume: {
|
||||
totalTonnes: 0,
|
||||
totalValue: 0,
|
||||
currency: 'ETB',
|
||||
currency: "ETB",
|
||||
ytdChangePct: 0,
|
||||
monthly: this.buildMonthlySeries(now, []),
|
||||
},
|
||||
@@ -190,8 +261,11 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
||||
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
|
||||
currency: string;
|
||||
total: number;
|
||||
} {
|
||||
if (totals.length === 0) return { currency: "ETB", total: 0 };
|
||||
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||
}
|
||||
|
||||
@@ -206,13 +280,29 @@ export class CompaniesService {
|
||||
now: Date,
|
||||
rows: { year: number; month: number; tonnes: number }[],
|
||||
): { month: string; tonnes: number }[] {
|
||||
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const labels = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||
const series: { month: string; tonnes: number }[] = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
|
||||
series.push({
|
||||
month: labels[d.getMonth()],
|
||||
tonnes: Math.round(byKey.get(key) ?? 0),
|
||||
});
|
||||
}
|
||||
return series;
|
||||
}
|
||||
@@ -224,7 +314,10 @@ export class CompaniesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
@@ -233,30 +326,38 @@ export class CompaniesService {
|
||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
||||
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
|
||||
if (dto.companyLocation !== undefined)
|
||||
companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined)
|
||||
companyUpdates.address = dto.companyAddress;
|
||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) {
|
||||
companyUpdates.businessLicense = dto.fanNumber;
|
||||
companyUpdates.fanNumber = dto.fanNumber;
|
||||
}
|
||||
|
||||
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||
if (dto.contactPersonName !== undefined)
|
||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
if (dto.contactPersonPhone !== undefined)
|
||||
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||
if (dto.generalManagerName !== undefined)
|
||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined)
|
||||
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||
if (dto.generalManagerPhone !== undefined)
|
||||
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaLocation !== undefined)
|
||||
attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||
|
||||
companyUpdates.attributes = attrUpdates;
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
@@ -270,7 +371,9 @@ export class CompaniesService {
|
||||
|
||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Profile with email ${dto.email} already exists`);
|
||||
throw new ConflictException(
|
||||
`Profile with email ${dto.email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.profilesRepo.create(dto);
|
||||
@@ -278,7 +381,8 @@ export class CompaniesService {
|
||||
|
||||
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -286,28 +390,119 @@ export class CompaniesService {
|
||||
return this.profilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
|
||||
await this.findCompanyById(dto.forwarderCompanyId);
|
||||
await this.findCompanyById(dto.clientCompanyId);
|
||||
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
|
||||
switch (companyType) {
|
||||
case "customer":
|
||||
return [ProfileType.importer, ProfileType.exporter];
|
||||
case "freight_forwarder":
|
||||
return [ProfileType.freightForwarder];
|
||||
case "dj_freight_forwarder":
|
||||
return [ProfileType.djFreightForwarder];
|
||||
case "transporter":
|
||||
return [ProfileType.transporter];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.ffClientsRepo.findRelationship(
|
||||
dto.forwarderCompanyId,
|
||||
dto.clientCompanyId,
|
||||
async createCompanyProfile(
|
||||
companyId: string,
|
||||
profileType?: ProfileType,
|
||||
): Promise<CompanyProfile> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
|
||||
const type = profileType ?? allowedTypes[0];
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (existing) {
|
||||
throw new ConflictException('This forwarder-client relationship already exists');
|
||||
throw new ConflictException(
|
||||
`Company already has a ${type} profile (${existing.reference})`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.ffClientsRepo.create(dto);
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
|
||||
return this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
|
||||
async createDefaultProfilesForCompany(
|
||||
companyId: string,
|
||||
): Promise<CompanyProfile[]> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const types = this.getProfileTypeForCompanyType(company.type);
|
||||
|
||||
const profiles: CompanyProfile[] = [];
|
||||
for (const type of types) {
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
if (!existing) {
|
||||
profiles.push(await this.createCompanyProfile(companyId, type));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFFClient(id: string): Promise<void> {
|
||||
const client = await this.ffClientsRepo.findById(id);
|
||||
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
|
||||
await this.ffClientsRepo.softDelete(id);
|
||||
if (profiles.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Company of type "${company.type}" must have at least one operational profile`,
|
||||
);
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add operational profile(s) to the current user's company (portal settings).
|
||||
* Add-only and idempotent: each requested type must be allowed for the
|
||||
* company's type, profiles that already exist are skipped (not re-created or
|
||||
* rejected), and the full updated list is returned.
|
||||
*/
|
||||
async addCompanyProfilesForUser(
|
||||
userId: string,
|
||||
types: ProfileType[],
|
||||
): Promise<CompanyProfile[]> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
|
||||
for (const type of types) {
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
if (existing) continue;
|
||||
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
|
||||
|
||||
const SEQUENCE_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.exporter]: "seq_company_profile_ex",
|
||||
[ProfileType.importer]: "seq_company_profile_im",
|
||||
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
|
||||
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
|
||||
[ProfileType.transporter]: "seq_company_profile_tr",
|
||||
};
|
||||
|
||||
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.exporter]: "EX",
|
||||
[ProfileType.importer]: "IM",
|
||||
[ProfileType.freightForwarder]: "FFE",
|
||||
[ProfileType.djFreightForwarder]: "FWJ",
|
||||
[ProfileType.transporter]: "TR",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyProfile)
|
||||
repo: Repository<CompanyProfile>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async generateReference(type: ProfileType): Promise<string> {
|
||||
const seqName = SEQUENCE_MAP[type];
|
||||
const result = await this.repository.query(
|
||||
`SELECT nextval('${seqName}') AS next_id`,
|
||||
);
|
||||
const nextId = result[0].next_id as number;
|
||||
const prefix = PREFIX_MAP[type];
|
||||
return `${prefix}-${String(nextId).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
relations: ["company"],
|
||||
});
|
||||
}
|
||||
|
||||
async findByType(
|
||||
companyId: string,
|
||||
type: ProfileType,
|
||||
): Promise<CompanyProfile | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, type },
|
||||
});
|
||||
}
|
||||
|
||||
async findByReference(reference: string): Promise<CompanyProfile | null> {
|
||||
return this.repository.findOne({ where: { reference } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class AddCompanyProfilesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(ProfileType, { each: true })
|
||||
types!: ProfileType[];
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class CompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
}
|
||||
|
||||
export class CreateCompanyWithProfileDto {
|
||||
@IsEnum(CompanyType)
|
||||
@@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
attributes?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CompanyProfileInputDto)
|
||||
companyProfiles?: CompanyProfileInputDto[];
|
||||
}
|
||||
|
||||
@@ -25,11 +25,6 @@ export class CreateCompanyDto {
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
|
||||
import { FFClientRelationship } from '../entities/ff-client.entity';
|
||||
|
||||
export class CreateFFClientDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
forwarderCompanyId!: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
clientCompanyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(FFClientRelationship)
|
||||
relationshipType?: FFClientRelationship;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canBookOnBehalf?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canViewDocuments?: boolean;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||
|
||||
export class ProfileResponseDto {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
companyType: string;
|
||||
companyEmail: string | null;
|
||||
companyPhone: string | null;
|
||||
companyLocation: string;
|
||||
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
|
||||
vatNumber: string | null;
|
||||
fanNumber: string | null;
|
||||
|
||||
companyProfiles: ResponseCompanyProfileDto[];
|
||||
|
||||
contactPersonName: string | null;
|
||||
contactPersonPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
this.companyId = company.id;
|
||||
this.companyName = company.name;
|
||||
this.companyType = company.type;
|
||||
this.companyProfiles =
|
||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||
[];
|
||||
this.companyEmail = company.email ?? null;
|
||||
this.companyPhone = company.phone ?? null;
|
||||
this.companyLocation = company.country;
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { CompanyProfile } from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class ResponseCompanyProfileDto {
|
||||
id: string;
|
||||
type: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
businessLicense?: string | null;
|
||||
attributes?: Record<string, any> | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: CompanyProfile) {
|
||||
this.id = profile.id;
|
||||
this.type = profile.type;
|
||||
this.reference = profile.reference;
|
||||
this.status = profile.status;
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.attributes = profile.attributes;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseCompanyDto {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,7 +31,6 @@ export class ResponseCompanyDto {
|
||||
status: CompanyStatus;
|
||||
tin: string;
|
||||
vatNumber?: string | null;
|
||||
businessLicense?: string | null;
|
||||
fanNumber?: string | null;
|
||||
country: string;
|
||||
address?: string | null;
|
||||
@@ -17,6 +39,7 @@ export class ResponseCompanyDto {
|
||||
website?: string | null;
|
||||
attributes?: Record<string, any> | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
companyProfiles?: ResponseCompanyProfileDto[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -27,7 +50,6 @@ export class ResponseCompanyDto {
|
||||
this.status = company.status;
|
||||
this.tin = company.tin;
|
||||
this.vatNumber = company.vatNumber;
|
||||
this.businessLicense = company.businessLicense;
|
||||
this.fanNumber = company.fanNumber;
|
||||
this.country = company.country;
|
||||
this.address = company.address;
|
||||
@@ -36,6 +58,7 @@ export class ResponseCompanyDto {
|
||||
this.website = company.website;
|
||||
this.attributes = company.attributes;
|
||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
|
||||
|
||||
export class ResponseFFClientDto {
|
||||
id: string;
|
||||
forwarderCompanyId: string;
|
||||
clientCompanyId: string;
|
||||
relationshipType: FFClientRelationship;
|
||||
canBookOnBehalf: boolean;
|
||||
canViewDocuments: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(client: FFClient) {
|
||||
this.id = client.id;
|
||||
this.forwarderCompanyId = client.forwarderCompanyId;
|
||||
this.clientCompanyId = client.clientCompanyId;
|
||||
this.relationshipType = client.relationshipType;
|
||||
this.canBookOnBehalf = client.canBookOnBehalf;
|
||||
this.canViewDocuments = client.canViewDocuments;
|
||||
this.createdAt = client.createdAt;
|
||||
this.updatedAt = client.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateFFClientDto } from './create-ff-client.dto';
|
||||
|
||||
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
export enum ProfileType {
|
||||
importer = "importer",
|
||||
exporter = "exporter",
|
||||
freightForwarder = "freight_forwarder",
|
||||
djFreightForwarder = "dj_freight_forwarder",
|
||||
transporter = "transporter",
|
||||
}
|
||||
|
||||
export enum ProfileStatus {
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
Suspended = "suspended",
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_profiles" })
|
||||
@Index(["reference"], { unique: true })
|
||||
@Index(["type"])
|
||||
@Index(["companyId"])
|
||||
export class CompanyProfile extends BaseEntity {
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, (company) => company.companyProfiles)
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company!: Company;
|
||||
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
|
||||
type!: ProfileType;
|
||||
|
||||
@Column({
|
||||
name: "reference",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: false,
|
||||
unique: true,
|
||||
})
|
||||
reference!: string;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: ProfileStatus.Active,
|
||||
})
|
||||
status!: ProfileStatus;
|
||||
|
||||
@Column({
|
||||
name: "business_license",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
businessLicense?: string | null;
|
||||
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
}
|
||||
@@ -1,79 +1,110 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { ExternalProfile } from './external-profile.entity';
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, OneToMany } from "typeorm";
|
||||
import { ExternalProfile } from "./external-profile.entity";
|
||||
import { CompanyProfile } from "./company-profile.entity";
|
||||
|
||||
export enum CompanyType {
|
||||
Customer = 'customer',
|
||||
Forwarder = 'forwarder',
|
||||
Transporter = 'transporter',
|
||||
Broker = 'broker',
|
||||
Customer = "customer",
|
||||
FreightForwarder = "freight_forwarder",
|
||||
DJFreightForwarder = "dj_freight_forwarder",
|
||||
Transporter = "transporter",
|
||||
}
|
||||
|
||||
export enum CompanyStatus {
|
||||
Active = 'active',
|
||||
Pending = 'pending',
|
||||
Suspended = 'suspended',
|
||||
Blacklisted = 'blacklisted',
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
Suspended = "suspended",
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'companies' })
|
||||
@Index(['tin'])
|
||||
@Index(['type'])
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
export class Company extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||
@Column({ name: "name", type: "varchar", length: 200 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||
type!: CompanyType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: CompanyStatus.Pending,
|
||||
})
|
||||
status!: CompanyStatus;
|
||||
|
||||
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
|
||||
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||
tin!: string;
|
||||
|
||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
||||
@Column({ name: "vat_number", type: "varchar", length: 50, nullable: true })
|
||||
vatNumber?: string | null;
|
||||
|
||||
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
|
||||
businessLicense?: string | null;
|
||||
|
||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
|
||||
@Column({ name: "fan_number", type: "varchar", length: 16, nullable: true })
|
||||
fanNumber?: string | null;
|
||||
|
||||
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
|
||||
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
|
||||
country!: string;
|
||||
|
||||
@Column({ name: 'address', type: 'text', nullable: true })
|
||||
@Column({ name: "address", type: "text", nullable: true })
|
||||
address?: string | null;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||
@Column({ name: "phone", type: "varchar", length: 20, nullable: true })
|
||||
phone?: string | null;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
||||
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
|
||||
email?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
|
||||
@Column({
|
||||
name: "contact_person_name",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
contactPersonName?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
|
||||
@Column({
|
||||
name: "contact_person_phone",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: true,
|
||||
})
|
||||
contactPersonPhone?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
|
||||
@Column({
|
||||
name: "general_manager_name",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerName?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
|
||||
@Column({
|
||||
name: "general_manager_email",
|
||||
type: "varchar",
|
||||
length: 150,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerEmail?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
|
||||
@Column({
|
||||
name: "general_manager_phone",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: true,
|
||||
})
|
||||
generalManagerPhone?: string | null;
|
||||
|
||||
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
||||
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
|
||||
website?: string | null;
|
||||
|
||||
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||
profiles?: ExternalProfile[];
|
||||
|
||||
@OneToMany(() => CompanyProfile, (profile) => profile.company)
|
||||
companyProfiles?: CompanyProfile[];
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
|
||||
export enum FFClientRelationship {
|
||||
ManagedAccount = 'managed_account',
|
||||
SubAgent = 'sub_agent',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'ff_clients' })
|
||||
@Unique(['forwarderCompanyId', 'clientCompanyId'])
|
||||
@Index(['forwarderCompanyId'])
|
||||
@Index(['clientCompanyId'])
|
||||
export class FFClient extends BaseEntity {
|
||||
@Column({ name: 'forwarder_company_id', type: 'uuid' })
|
||||
forwarderCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'forwarder_company_id' })
|
||||
forwarderCompany!: Company;
|
||||
|
||||
@Column({ name: 'client_company_id', type: 'uuid' })
|
||||
clientCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'client_company_id' })
|
||||
clientCompany!: Company;
|
||||
|
||||
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
|
||||
relationshipType!: FFClientRelationship;
|
||||
|
||||
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
|
||||
canBookOnBehalf!: boolean;
|
||||
|
||||
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
|
||||
canViewDocuments!: boolean;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FFClientRepository extends BaseRepository<FFClient> {
|
||||
constructor(
|
||||
@InjectRepository(FFClient)
|
||||
repo: Repository<FFClient>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||
return this.repository.find({ where: { forwarderCompanyId } as any });
|
||||
}
|
||||
|
||||
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
|
||||
return this.repository.find({ where: { clientCompanyId } as any });
|
||||
}
|
||||
|
||||
async findRelationship(
|
||||
forwarderCompanyId: string,
|
||||
clientCompanyId: string,
|
||||
): Promise<FFClient | null> {
|
||||
return this.repository.findOne({
|
||||
where: { forwarderCompanyId, clientCompanyId } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// src/modules/customers/customers.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Controller("customers")
|
||||
@FreightAdmin()
|
||||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
|
||||
return this.customersService.create(createCustomerDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(): Promise<Customer[]> {
|
||||
return this.customersService.findAll();
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@ApiOperation({ summary: "Get customer statistics" })
|
||||
getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||
return this.customersService.getStats();
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
searchByName(@Query("name") name: string): Promise<Customer[]> {
|
||||
return this.customersService.searchByName(name);
|
||||
}
|
||||
|
||||
@Get("email/:email")
|
||||
findByEmail(@Param("email") email: string): Promise<Customer> {
|
||||
return this.customersService.findByEmail(email);
|
||||
}
|
||||
|
||||
@Get("vat/:vatNumber")
|
||||
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
|
||||
return this.customersService.findByVatNumber(vatNumber);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
|
||||
return this.customersService.findById(id);
|
||||
}
|
||||
|
||||
// @Get("user/:userId")
|
||||
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
||||
// return this.customersService.findByUserId(userId);
|
||||
// }
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update a customer" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCustomerDto,
|
||||
): Promise<Customer> {
|
||||
return this.customersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@ApiOperation({ summary: "Soft-delete a customer" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||
return this.customersService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersRepository } from "./customers.repository";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Customer])],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService, CustomersRepository],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -1,117 +0,0 @@
|
||||
// import { BaseRepository } from "@edr/api-common";
|
||||
// import { EntityRepository } from "typeorm";
|
||||
|
||||
// src/modules/customers/customers.repository.ts
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
|
||||
@Injectable()
|
||||
export class CustomersRepository {
|
||||
constructor(
|
||||
@InjectRepository(Customer)
|
||||
private readonly repository: Repository<Customer>,
|
||||
) { }
|
||||
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const customer = this.repository.create(dto);
|
||||
return await this.repository.save(customer);
|
||||
}
|
||||
|
||||
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
|
||||
return await this.repository.find(options);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Customer[]> {
|
||||
return await this.repository
|
||||
.createQueryBuilder("customer")
|
||||
.where("customer.companyName ILIKE :name", { name: `%${name}%` })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
|
||||
if (!email && !vatNumber) return null;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('customer');
|
||||
|
||||
if (email && vatNumber) {
|
||||
queryBuilder.where('customer.email = :email', { email })
|
||||
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
} else if (email) {
|
||||
queryBuilder.where('customer.email = :email', { email });
|
||||
} else if (vatNumber) {
|
||||
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
}
|
||||
|
||||
return await queryBuilder.getOne();
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
|
||||
await this.repository.update(id, updates);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete(id);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
async count(where?: any): Promise<number> {
|
||||
if (where?.createdAt) {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('customer')
|
||||
.where('customer.createdAt >= :date', { date: where.createdAt })
|
||||
.getCount();
|
||||
return result;
|
||||
}
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
|
||||
const queryBuilder = this.repository.createQueryBuilder('customer')
|
||||
.where('customer.email = :email', { email });
|
||||
|
||||
if (vatNumber) {
|
||||
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
}
|
||||
|
||||
const count = await queryBuilder.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async countWithVatNumber(): Promise<number> {
|
||||
const count = await this.repository
|
||||
.createQueryBuilder('customer')
|
||||
.where('customer.vatNumber IS NOT NULL')
|
||||
.andWhere("customer.vatNumber != ''")
|
||||
.getCount();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
getRepository(): Repository<Customer> {
|
||||
return this.repository;
|
||||
}
|
||||
softDelete(id: string): any {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { CustomersRepository } from "./customers.repository";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||
|
||||
/** Create a new customer */
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const exists = await this.customersRepository.existsByUniqueFields(
|
||||
dto.email,
|
||||
dto.vatNumber,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
throw new ConflictException(
|
||||
"Customer with same email or VAT number already exists",
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
return this.customersRepository.create(dto);
|
||||
}
|
||||
|
||||
/** Get all customers */
|
||||
findAll(): Promise<Customer[]> {
|
||||
return this.customersRepository.findAll({
|
||||
order: { companyName: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get customer by ID */
|
||||
async findById(id: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findById(id);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
// async findByUserId(userId: string): Promise<Customer> {
|
||||
// const customer = await this.customersRepository.findByUserId(userId);
|
||||
|
||||
// if (!customer) {
|
||||
// throw new NotFoundException(`Customer with ID ${userId} not found`);
|
||||
// }
|
||||
|
||||
// return customer;
|
||||
//}
|
||||
|
||||
/** Get customer by email */
|
||||
async findByEmail(email: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByEmail(email);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer with email ${email} not found`);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Get customer by VAT number */
|
||||
async findByVatNumber(vatNumber: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByVatNumber(vatNumber);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(
|
||||
`Customer with VAT number ${vatNumber} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Search customers by name */
|
||||
searchByName(name: string): Promise<Customer[]> {
|
||||
return this.customersRepository.findByName(name);
|
||||
}
|
||||
|
||||
/** Update customer */
|
||||
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
|
||||
await this.findById(id);
|
||||
|
||||
// Validate VAT number if provided
|
||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
// // Check email conflict
|
||||
// if (dto.email) {
|
||||
// const existing = await this.customersRepository.findByEmail(dto.email);
|
||||
|
||||
// // if (existing && existing.userId !== id) {
|
||||
// // throw new ConflictException(
|
||||
// // `Customer with email "${dto.email}" already exists`,
|
||||
// // );
|
||||
// // }
|
||||
// }
|
||||
|
||||
const updated = await this.customersRepository.update(id, dto);
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Customer ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Delete customer (soft delete) */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.customersRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Get customer statistics */
|
||||
async getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||
const total = await this.customersRepository.count();
|
||||
const withVatNumber = await this.customersRepository.countWithVatNumber();
|
||||
|
||||
return { total, withVatNumber };
|
||||
}
|
||||
|
||||
delete(id: string): any {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
IsNotEmpty,
|
||||
Length,
|
||||
Matches,
|
||||
} from "class-validator";
|
||||
|
||||
// Enums
|
||||
export enum CustomerStatusDto {
|
||||
Active = "Active",
|
||||
Pending = "Pending",
|
||||
Inactive = "Inactive",
|
||||
}
|
||||
|
||||
export enum CustomerTypeDto {
|
||||
Importer = "Importer",
|
||||
Exporter = "Exporter",
|
||||
Supplier = "Supplier",
|
||||
}
|
||||
|
||||
// DTO
|
||||
export class CreateCustomerDto {
|
||||
// Basic identity
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
phone!: string;
|
||||
|
||||
// Company info
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
companyName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
companyEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
companyPhone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
companyLocation!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
companyAddress!: string;
|
||||
|
||||
// Classification
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerTypeDto)
|
||||
customerType?: CustomerTypeDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerStatusDto)
|
||||
status?: CustomerStatusDto;
|
||||
|
||||
// Legal identifiers
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
|
||||
tinNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(16, 16)
|
||||
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
|
||||
fanNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
vatNumber!: string;
|
||||
|
||||
// Contact person
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
contactPersonName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
contactPersonPhone!: string;
|
||||
|
||||
// Management
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
generalManagerName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
generalManagerEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
generalManagerPhone!: string;
|
||||
|
||||
// POA (Power of Attorney)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
poaName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
poaPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
poaEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
poaLocation?: string;
|
||||
|
||||
// Extra
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// src/modules/customers/dto/response-customer.dto.ts
|
||||
import { Customer } from '../entities/customer.entity';
|
||||
|
||||
export class ResponseCustomerDto {
|
||||
//UserId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
companyPhone: string;
|
||||
companyLocation: string;
|
||||
companyAddress: string;
|
||||
contactPersonName: string;
|
||||
contactPersonPhone: string;
|
||||
tinNumber: string;
|
||||
vatNumber?: string;
|
||||
fanNumber: string;
|
||||
generalManagerName: string;
|
||||
generalManagerEmail: string;
|
||||
generalManagerPhone: string;
|
||||
poaName?: string;
|
||||
poaPhone?: string;
|
||||
poaAddress?: string;
|
||||
poaEmail?: string;
|
||||
poaLocation?: string;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(customer: Customer) {
|
||||
//this.UserId = customer.userId;
|
||||
this.firstName = customer.firstName;
|
||||
this.lastName = customer.lastName;
|
||||
this.email = customer.email;
|
||||
this.phone = customer.phone;
|
||||
this.companyName = customer.companyName;
|
||||
this.companyEmail = customer.companyEmail;
|
||||
this.companyPhone = customer.companyPhone;
|
||||
this.companyLocation = customer.companyLocation;
|
||||
this.companyAddress = customer.companyAddress;
|
||||
this.contactPersonName = customer.contactPersonName;
|
||||
this.contactPersonPhone = customer.contactPersonPhone;
|
||||
this.tinNumber = customer.tinNumber;
|
||||
this.vatNumber = customer.vatNumber ?? undefined;
|
||||
this.fanNumber = customer.fanNumber;
|
||||
this.generalManagerName = customer.generalManagerName;
|
||||
this.generalManagerEmail = customer.generalManagerEmail;
|
||||
this.generalManagerPhone = customer.generalManagerPhone;
|
||||
this.poaName = customer.poaName ?? '';
|
||||
this.poaPhone = customer.poaPhone ?? '';
|
||||
this.poaAddress = customer.poaAddress ?? '';
|
||||
this.poaEmail = customer.poaEmail ?? '';
|
||||
this.poaLocation = customer.poaLocation ?? '';
|
||||
this.notes = customer.notes ?? '';
|
||||
this.createdAt = customer.createdAt;
|
||||
this.updatedAt = customer.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// src/modules/customers/dto/update-customer.dto.ts
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCustomerDto } from './create-customer.dto';
|
||||
|
||||
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
|
||||
email?: string;
|
||||
vatNumber?: string;
|
||||
// Add any other properties you need to access directly
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'customers' })
|
||||
@Index(['email'])
|
||||
//@Index(['userId'])
|
||||
@Index(['tinNumber'])
|
||||
@Index(['fanNumber'])
|
||||
export class Customer extends BaseEntity {
|
||||
//@Column({ name: 'user_id', type: 'uuid' })
|
||||
//userId!: string;
|
||||
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||
firstName!: string;
|
||||
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||
lastName!: string;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20 })
|
||||
phone!: string;
|
||||
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200 })
|
||||
companyName!: string;
|
||||
|
||||
@Column({ name: 'company_email', type: 'varchar', length: 150 })
|
||||
companyEmail!: string;
|
||||
|
||||
@Column({ name: 'company_phone', type: 'varchar', length: 20 })
|
||||
companyPhone!: string;
|
||||
|
||||
@Column({ name: 'company_location', type: 'varchar', length: 100 })
|
||||
companyLocation!: string;
|
||||
|
||||
@Column({ name: 'company_address', type: 'text' })
|
||||
companyAddress!: string;
|
||||
|
||||
@Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true })
|
||||
customerType?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, nullable: true })
|
||||
status?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_name', type: 'varchar', length: 100 })
|
||||
contactPersonName!: string;
|
||||
|
||||
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20 })
|
||||
contactPersonPhone!: string;
|
||||
|
||||
@Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true })
|
||||
tinNumber!: string;
|
||||
|
||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
||||
vatNumber?: string | null;
|
||||
|
||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true })
|
||||
fanNumber!: string;
|
||||
|
||||
@Column({ name: 'general_manager_name', type: 'varchar', length: 100 })
|
||||
generalManagerName!: string;
|
||||
|
||||
@Column({ name: 'general_manager_email', type: 'varchar', length: 150 })
|
||||
generalManagerEmail!: string;
|
||||
|
||||
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20 })
|
||||
generalManagerPhone!: string;
|
||||
|
||||
@Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true })
|
||||
poaName?: string | null;
|
||||
|
||||
@Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true })
|
||||
poaPhone?: string | null;
|
||||
|
||||
@Column({ name: 'poa_address', type: 'text', nullable: true })
|
||||
poaAddress?: string | null;
|
||||
|
||||
@Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true })
|
||||
poaEmail?: string | null;
|
||||
|
||||
@Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true })
|
||||
poaLocation?: string | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Employee } from '@tria-plc/iamapi-common';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { Employee } from "@tria-plc/iamapi-common";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Customer } from '../customers/entities/customer.entity';
|
||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { OverviewController } from './overview.controller';
|
||||
import { OverviewRepository } from './overview.repository';
|
||||
import { OverviewService } from './overview.service';
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||
import { Container } from "../container-management/entities/container.entity";
|
||||
import { Company } from "../companies/entities/company.entity";
|
||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||
import { Train } from "../trains/entities/train.entity";
|
||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||
import { OverviewController } from "./overview.controller";
|
||||
import { OverviewRepository } from "./overview.repository";
|
||||
import { OverviewService } from "./overview.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Booking,
|
||||
PaymentEntity,
|
||||
Customer,
|
||||
Company,
|
||||
Train,
|
||||
Wagon,
|
||||
Container,
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { Employee } from '@tria-plc/iamapi-common';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Repository, ObjectLiteral } from 'typeorm';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
import { Employee } from "@tria-plc/iamapi-common";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Repository, ObjectLiteral } from "typeorm";
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Customer } from '../customers/entities/customer.entity';
|
||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||
import { Container } from "../container-management/entities/container.entity";
|
||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||
import { Train } from "../trains/entities/train.entity";
|
||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||
import {
|
||||
OVERVIEW_CLOSED_STATUSES,
|
||||
OVERVIEW_IN_APPROVAL_STATUSES,
|
||||
OVERVIEW_NEEDS_ACTION_STATUSES,
|
||||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||
} from './overview.constants';
|
||||
} from "./overview.constants";
|
||||
import { Company } from "../companies/entities/company.entity";
|
||||
|
||||
export type OverviewBookingKpisRow = {
|
||||
totalActive: number;
|
||||
@@ -46,8 +46,8 @@ export class OverviewRepository {
|
||||
private readonly bookingRepository: Repository<Booking>,
|
||||
@InjectRepository(PaymentEntity)
|
||||
private readonly paymentRepository: Repository<PaymentEntity>,
|
||||
@InjectRepository(Customer)
|
||||
private readonly customerRepository: Repository<Customer>,
|
||||
@InjectRepository(Company)
|
||||
private readonly companyRepository: Repository<Company>,
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepository: Repository<Train>,
|
||||
@InjectRepository(Wagon)
|
||||
@@ -64,28 +64,28 @@ export class OverviewRepository {
|
||||
|
||||
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||
const row = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.createQueryBuilder("booking")
|
||||
.select(
|
||||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||||
'totalActive',
|
||||
"totalActive",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
|
||||
'needsAction',
|
||||
"needsAction",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
|
||||
'urgent',
|
||||
"urgent",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
|
||||
'inApproval',
|
||||
"inApproval",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
|
||||
'submittedToday',
|
||||
"submittedToday",
|
||||
)
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.setParameters({
|
||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||||
@@ -112,9 +112,9 @@ export class OverviewRepository {
|
||||
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
||||
await Promise.all([
|
||||
this.trainRepository
|
||||
.createQueryBuilder('train')
|
||||
.where('train.deleted_at IS NULL')
|
||||
.andWhere('train.status IN (:...statuses)', {
|
||||
.createQueryBuilder("train")
|
||||
.where("train.deleted_at IS NULL")
|
||||
.andWhere("train.status IN (:...statuses)", {
|
||||
statuses: [
|
||||
Freight.TrainStatus.InService,
|
||||
Freight.TrainStatus.Scheduled,
|
||||
@@ -122,39 +122,46 @@ export class OverviewRepository {
|
||||
})
|
||||
.getCount(),
|
||||
this.wagonRepository
|
||||
.createQueryBuilder('wagon')
|
||||
.where('wagon.deleted_at IS NULL')
|
||||
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
|
||||
.createQueryBuilder("wagon")
|
||||
.where("wagon.deleted_at IS NULL")
|
||||
.andWhere("wagon.status = :status", {
|
||||
status: Freight.WagonStatus.Available,
|
||||
})
|
||||
.getCount(),
|
||||
this.containerRepository
|
||||
.createQueryBuilder('container')
|
||||
.where('container.deleted_at IS NULL')
|
||||
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
|
||||
.createQueryBuilder("container")
|
||||
.where("container.deleted_at IS NULL")
|
||||
.andWhere("container.status = :status", { status: "IN_TRANSIT" })
|
||||
.getCount(),
|
||||
this.cargoRepository
|
||||
.createQueryBuilder('cargo')
|
||||
.where('cargo.deleted_at IS NULL')
|
||||
.andWhere('cargo.status IN (:...statuses)', {
|
||||
statuses: ['LOADED', 'IN_TRANSIT'],
|
||||
.createQueryBuilder("cargo")
|
||||
.where("cargo.deleted_at IS NULL")
|
||||
.andWhere("cargo.status IN (:...statuses)", {
|
||||
statuses: ["LOADED", "IN_TRANSIT"],
|
||||
})
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
|
||||
return {
|
||||
trainsActive,
|
||||
wagonsAvailable,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
};
|
||||
}
|
||||
|
||||
async getCustomerKpis(): Promise<{
|
||||
totalCustomers: number;
|
||||
newCustomersThisMonth: number;
|
||||
}> {
|
||||
const row = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select('COUNT(*)::int', 'totalCustomers')
|
||||
const row = await this.companyRepository
|
||||
.createQueryBuilder("customer")
|
||||
.select("COUNT(*)::int", "totalCustomers")
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
|
||||
'newCustomersThisMonth',
|
||||
"newCustomersThisMonth",
|
||||
)
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.where("customer.deleted_at IS NULL")
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
return {
|
||||
@@ -170,26 +177,26 @@ export class OverviewRepository {
|
||||
successfulPaymentsMtd: number;
|
||||
}> {
|
||||
const revenueRow = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.createQueryBuilder("payment")
|
||||
.select(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
'revenueMtdEtb',
|
||||
"revenueMtdEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
'revenueMtdUsd',
|
||||
"revenueMtdUsd",
|
||||
)
|
||||
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||
)
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
const pendingPayments = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.where('payment.status IN (:...statuses)', {
|
||||
statuses: ['action-required', 'processing'],
|
||||
.createQueryBuilder("payment")
|
||||
.where("payment.status IN (:...statuses)", {
|
||||
statuses: ["action-required", "processing"],
|
||||
})
|
||||
.getCount();
|
||||
|
||||
@@ -201,7 +208,10 @@ export class OverviewRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
|
||||
async getStaffKpis(): Promise<{
|
||||
activeEmployees: number;
|
||||
activeUsers: number;
|
||||
}> {
|
||||
const [activeEmployees, activeUsers] = await Promise.all([
|
||||
this.employeeRepository.count({
|
||||
where: { isCurrent: true },
|
||||
@@ -217,15 +227,17 @@ export class OverviewRepository {
|
||||
return { activeEmployees, activeUsers };
|
||||
}
|
||||
|
||||
async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
async getBookingTrend(
|
||||
days: number,
|
||||
): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.createQueryBuilder("booking")
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('booking.created_at::date')
|
||||
.orderBy('booking.created_at::date', 'ASC')
|
||||
.groupBy("booking.created_at::date")
|
||||
.orderBy("booking.created_at::date", "ASC")
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -236,11 +248,11 @@ export class OverviewRepository {
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.groupBy('booking.status')
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.groupBy("booking.status")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return Object.fromEntries(
|
||||
@@ -252,26 +264,26 @@ export class OverviewRepository {
|
||||
days: number,
|
||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.createQueryBuilder("payment")
|
||||
.select(
|
||||
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
|
||||
'date',
|
||||
"date",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||
'amountEtb',
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||
'amountUsd',
|
||||
"amountUsd",
|
||||
)
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
|
||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
||||
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -283,18 +295,18 @@ export class OverviewRepository {
|
||||
|
||||
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoin('booking.company', 'company')
|
||||
.select('booking.id', 'id')
|
||||
.addSelect('booking.reference', 'reference')
|
||||
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
|
||||
.addSelect('booking.status', 'status')
|
||||
.addSelect('booking.priority_score', 'priorityScore')
|
||||
.addSelect('booking.total_amount', 'totalAmount')
|
||||
.addSelect('booking.payment_currency', 'paymentCurrency')
|
||||
.addSelect('booking.created_at', 'createdAt')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.orderBy('booking.created_at', 'DESC')
|
||||
.createQueryBuilder("booking")
|
||||
.leftJoin("booking.company", "company")
|
||||
.select("booking.id", "id")
|
||||
.addSelect("booking.reference", "reference")
|
||||
.addSelect("COALESCE(company.name, '—')", "customerLabel")
|
||||
.addSelect("booking.status", "status")
|
||||
.addSelect("booking.priority_score", "priorityScore")
|
||||
.addSelect("booking.total_amount", "totalAmount")
|
||||
.addSelect("booking.payment_currency", "paymentCurrency")
|
||||
.addSelect("booking.created_at", "createdAt")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.orderBy("booking.created_at", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{
|
||||
id: string;
|
||||
@@ -319,15 +331,17 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
|
||||
async getBookingsByFreightType(): Promise<
|
||||
{ label: string; count: number }[]
|
||||
> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.freight_type', 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.freight_type", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('booking.freight_type')
|
||||
.orderBy('count', 'DESC')
|
||||
.groupBy("booking.freight_type")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -338,13 +352,13 @@ export class OverviewRepository {
|
||||
|
||||
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.select('booking.payment_currency', 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.payment_currency", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('booking.payment_currency')
|
||||
.orderBy('count', 'DESC')
|
||||
.groupBy("booking.payment_currency")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -355,11 +369,11 @@ export class OverviewRepository {
|
||||
|
||||
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('payment.status')
|
||||
.orderBy('count', 'DESC')
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.groupBy("payment.status")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -372,20 +386,25 @@ export class OverviewRepository {
|
||||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
||||
> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.method', 'method')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.method", "method")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
|
||||
'amountEtb',
|
||||
"amountEtb",
|
||||
)
|
||||
.addSelect(
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||
'amountUsd',
|
||||
"amountUsd",
|
||||
)
|
||||
.groupBy('payment.method')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
|
||||
.groupBy("payment.method")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{
|
||||
method: string;
|
||||
count: string;
|
||||
amountEtb: string;
|
||||
amountUsd: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
method: row.method,
|
||||
@@ -395,16 +414,18 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
|
||||
async getRevenueByCurrency(): Promise<
|
||||
{ currency: string; amount: number }[]
|
||||
> {
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder('payment')
|
||||
.select('payment.currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
|
||||
.where('payment.status = :status', { status: 'success' })
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.currency", "currency")
|
||||
.addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||
)
|
||||
.groupBy('payment.currency')
|
||||
.groupBy("payment.currency")
|
||||
.getRawMany<{ currency: string; amount: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -413,20 +434,28 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.trainRepository, 'train');
|
||||
async getTrainStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.trainRepository, "train");
|
||||
}
|
||||
|
||||
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.wagonRepository, 'wagon');
|
||||
async getWagonStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.wagonRepository, "wagon");
|
||||
}
|
||||
|
||||
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.containerRepository, 'container');
|
||||
async getContainerStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.containerRepository, "container");
|
||||
}
|
||||
|
||||
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusBreakdown(this.cargoRepository, 'cargo');
|
||||
async getCargoStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.cargoRepository, "cargo");
|
||||
}
|
||||
|
||||
private async statusBreakdown(
|
||||
@@ -435,11 +464,11 @@ export class OverviewRepository {
|
||||
): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await repository
|
||||
.createQueryBuilder(alias)
|
||||
.select(`${alias}.status`, 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.select(`${alias}.status`, "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where(`${alias}.deleted_at IS NULL`)
|
||||
.groupBy(`${alias}.status`)
|
||||
.orderBy('count', 'DESC')
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -448,15 +477,19 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('customer.created_at::date')
|
||||
.orderBy('customer.created_at::date', 'ASC')
|
||||
async getCustomerGrowthTrend(
|
||||
days: number,
|
||||
): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.companyRepository
|
||||
.createQueryBuilder("customer")
|
||||
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("customer.deleted_at IS NULL")
|
||||
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||||
days,
|
||||
})
|
||||
.groupBy("customer.created_at::date")
|
||||
.orderBy("customer.created_at::date", "ASC")
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -466,13 +499,16 @@ export class OverviewRepository {
|
||||
}
|
||||
|
||||
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.customerRepository
|
||||
.createQueryBuilder('customer')
|
||||
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('customer.deleted_at IS NULL')
|
||||
.groupBy('customer.customer_type')
|
||||
.orderBy('count', 'DESC')
|
||||
const rows = await this.companyRepository
|
||||
.createQueryBuilder("customer")
|
||||
.select(
|
||||
`COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
|
||||
"label",
|
||||
)
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("customer.deleted_at IS NULL")
|
||||
.groupBy("customer.type")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -481,16 +517,18 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
|
||||
async getTopCustomersByBookings(
|
||||
limit: number,
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoin('booking.company', 'company')
|
||||
.select(`COALESCE(company.name, 'Unknown')`, 'label')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('booking.deleted_at IS NULL')
|
||||
.createQueryBuilder("booking")
|
||||
.leftJoin("booking.company", "company")
|
||||
.select(`COALESCE(company.name, 'Unknown')`, "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.groupBy('company.name')
|
||||
.orderBy('count', 'DESC')
|
||||
.groupBy("company.name")
|
||||
.orderBy("count", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
@@ -502,11 +540,11 @@ export class OverviewRepository {
|
||||
|
||||
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.select('user.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.groupBy('user.status')
|
||||
.orderBy('count', 'DESC')
|
||||
.createQueryBuilder("user")
|
||||
.select("user.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.groupBy("user.status")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -515,15 +553,19 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
async getEmployeeGrowthTrend(
|
||||
days: number,
|
||||
): Promise<{ date: string; count: number }[]> {
|
||||
const rows = await this.employeeRepository
|
||||
.createQueryBuilder('employee')
|
||||
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('employee.is_current = true')
|
||||
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy('employee.created_at::date')
|
||||
.orderBy('employee.created_at::date', 'ASC')
|
||||
.createQueryBuilder("employee")
|
||||
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("employee.is_current = true")
|
||||
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||||
days,
|
||||
})
|
||||
.groupBy("employee.created_at::date")
|
||||
.orderBy("employee.created_at::date", "ASC")
|
||||
.getRawMany<{ date: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -538,16 +580,16 @@ export class OverviewRepository {
|
||||
where: { isActive: true, status: EUserStatus.ACCEPTED },
|
||||
}),
|
||||
this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.is_active = false OR user.status != :status', {
|
||||
.createQueryBuilder("user")
|
||||
.where("user.is_active = false OR user.status != :status", {
|
||||
status: EUserStatus.ACCEPTED,
|
||||
})
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return [
|
||||
{ label: 'Active', count: active },
|
||||
{ label: 'Inactive', count: inactive },
|
||||
{ label: "Active", count: active },
|
||||
{ label: "Inactive", count: inactive },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
|
||||
@@ -145,9 +145,7 @@ export class PaymentService {
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
console.log("bookingbooking",booking)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
|
||||
console.log("amountminor",amountMinor)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
@@ -159,8 +157,8 @@ export class PaymentService {
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
|
||||
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
|
||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||
});
|
||||
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
|
||||
@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
// A train can be rescheduled (with or without bookings) at any time UNLESS it
|
||||
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
|
||||
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
|
||||
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
||||
throw new BadRequestException('Cannot reschedule a dispatched train');
|
||||
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
|
||||
}
|
||||
if (schedule.status === TrainScheduleStatus.Arrived) {
|
||||
throw new BadRequestException('Cannot reschedule a train that has already arrived');
|
||||
}
|
||||
if (schedule.status === TrainScheduleStatus.Cancelled) {
|
||||
throw new BadRequestException('Cannot reschedule a cancelled train');
|
||||
}
|
||||
|
||||
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
||||
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
|
||||
}
|
||||
}
|
||||
|
||||
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||
// only call it when something is actually being (re)assigned — the new
|
||||
// departure date above is the meaningful change for an empty train. The
|
||||
// empty-train branch returns the same schedule-detail shape as the assign
|
||||
// path so callers get a consistent response.
|
||||
const assignResult = dto.finalBookingIds.length
|
||||
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||
bookingIds: dto.finalBookingIds,
|
||||
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||
});
|
||||
})
|
||||
: {
|
||||
...(await this.trainSchedulingService.getContainerTrainScheduleById(
|
||||
scheduleId,
|
||||
)),
|
||||
warnings: [] as string[],
|
||||
deferredBookings: [] as unknown[],
|
||||
};
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent({
|
||||
trainScheduleId: scheduleId,
|
||||
|
||||
@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
|
||||
* key for day-level booking pools — it must match the day the portal calendar
|
||||
* renders, so always derive day keys through this (never `toISOString().slice`).
|
||||
*/
|
||||
export function eatDay(date: Date): string {
|
||||
const { year, month, day } = eatParts(date);
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||
function eatToUtc(
|
||||
year: number,
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
let bookingsRepository: {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findBatchPoolByRouteDay: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
getBookableSchedules: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
let notifier: {
|
||||
payNow: jest.Mock;
|
||||
secured: jest.Mock;
|
||||
expired: jest.Mock;
|
||||
unplaced: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const bookingRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
// WagonType.find() / global-rules find() fall back to defaults when empty.
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
notifier = {
|
||||
payNow: jest.fn(),
|
||||
secured: jest.fn(),
|
||||
expired: jest.fn(),
|
||||
unplaced: jest.fn(),
|
||||
};
|
||||
|
||||
service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||
});
|
||||
|
||||
describe('fillRouteDay — day-level distribution', () => {
|
||||
const originYardId = 'yard-origin';
|
||||
const destinationYardId = 'yard-dest';
|
||||
const day = '2026-06-20';
|
||||
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
|
||||
const trainA = 'train-a';
|
||||
const trainB = 'train-b';
|
||||
|
||||
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
|
||||
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
|
||||
|
||||
const commercial = (id: string, priority: number): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
isGovernment: false,
|
||||
priorityScore: priority,
|
||||
status: 'FULLY_EXECUTED',
|
||||
wagonsRequired: 1,
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainers: [],
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
// Two OPEN trains on the same route + day, train A earlier than train B.
|
||||
trainSchedulingService.getBookableSchedules.mockResolvedValue([
|
||||
{
|
||||
id: trainA,
|
||||
scheduleDate: '2026-06-20T06:00:00.000Z',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
{
|
||||
id: trainB,
|
||||
scheduleDate: '2026-06-20T09:00:00.000Z',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
maxWagons: 1,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
trainSetId: `set-${id}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||||
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
commercial('hi', 30),
|
||||
commercial('mid', 20),
|
||||
commercial('lo', 10),
|
||||
]);
|
||||
|
||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
// Both trains were processed.
|
||||
expect(touched).toEqual([trainA, trainB]);
|
||||
// Highest priority reserved on train A, next on train B (commercial → reserve).
|
||||
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||
expect(reservedOn).toEqual(['hi', 'mid']);
|
||||
// The third booking fits no train and is reported unplaced (and only it).
|
||||
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
|
||||
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
|
||||
});
|
||||
|
||||
it('reserves the chosen train id on each commercial booking', async () => {
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'hi',
|
||||
expect.objectContaining({
|
||||
trainScheduleId: trainA,
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -42,6 +42,14 @@ interface Capacity {
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
/** EAT calendar day, `yyyy-MM-dd`. */
|
||||
day: string;
|
||||
}
|
||||
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
for (const s of open) {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
await this.processRouteDay(group);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
|
||||
this.logger.warn(
|
||||
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const reserved = await this.dataSource
|
||||
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
|
||||
/**
|
||||
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
|
||||
* (contract sign, payment). Day-level pooling distributes across all of that
|
||||
* day's trains, so a single schedule id maps to its whole route-day group.
|
||||
*/
|
||||
enqueueScheduleProcessing(scheduleId: string): void {
|
||||
void this.processSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
|
||||
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(
|
||||
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
|
||||
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (!schedule?.scheduledDepartureDate) return;
|
||||
await this.processRouteDay({
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day: eatDay(schedule.scheduledDepartureDate),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level pipeline: distribute the (route, day) pool across all its trains,
|
||||
* then settle / reconcile / assign wagons per schedule (those steps stay
|
||||
* schedule-scoped — only the fill is day-level).
|
||||
*/
|
||||
async processRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
const scheduleIds = await this.fillRouteDay(
|
||||
group.originYardId,
|
||||
group.destinationYardId,
|
||||
group.day,
|
||||
);
|
||||
for (const scheduleId of scheduleIds) {
|
||||
await this.settleDueReservations(scheduleId);
|
||||
await this.reconcilePaidUnlinked(scheduleId);
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
||||
async processSchedule(scheduleId: string): Promise<void> {
|
||||
await this.fillSchedule(scheduleId);
|
||||
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
const groups = new Map<string, RouteDayGroup>();
|
||||
for (const s of open) {
|
||||
if (!s.scheduledDepartureDate) continue;
|
||||
const day = eatDay(s.scheduledDepartureDate);
|
||||
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
originYardId: s.originStationId,
|
||||
destinationYardId: s.destinationStationId,
|
||||
day,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
private groupLabel(group: RouteDayGroup): string {
|
||||
return `${group.originYardId}→${group.destinationYardId} on ${group.day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
||||
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
||||
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||
async runBatchFill(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
|
||||
for (const s of open) {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
await this.processRouteDay(group);
|
||||
} catch (err) {
|
||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
||||
this.logger.error(
|
||||
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking);
|
||||
await this.reserve(booking, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget = this.subtract(budget, need);
|
||||
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
|
||||
* priority, filling each train (earliest departure first) until it's full and
|
||||
* spilling overflow to the next. Government bookings that fit no train preempt
|
||||
* lower-priority commercial; bookings that fit no train at all stay pending and
|
||||
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
|
||||
* (or that had remaining pool work) so the caller can settle them per-schedule.
|
||||
*/
|
||||
async fillRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<string[]> {
|
||||
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
||||
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const scheduleIds = bookable
|
||||
.filter(
|
||||
(s) =>
|
||||
s.bookingWindowStatus === 'OPEN' &&
|
||||
s.scheduleDate != null &&
|
||||
eatDay(new Date(s.scheduleDate)) === day,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
|
||||
if (scheduleIds.length === 0) return [];
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
|
||||
// Live per-schedule budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
|
||||
continue;
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
|
||||
// First train (earliest departure) that fits this booking as-is.
|
||||
let target = trains.find((t) => this.fits(need, t.budget));
|
||||
|
||||
if (!target && booking.isGovernment) {
|
||||
// Government booking fits nowhere on its own — try to preempt commercial
|
||||
// on each train (earliest first) until one frees enough room.
|
||||
for (const t of trains) {
|
||||
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
|
||||
if (this.fits(need, t.budget)) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// Fits no train this day — stays in the pool, retried next batch.
|
||||
this.notifier.unplaced(booking, day);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(target.id, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget = this.subtract(target.budget, need);
|
||||
}
|
||||
|
||||
for (const t of trains) {
|
||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
|
||||
/** Reserve capacity for a commercial booking and open its pay window. */
|
||||
private async reserve(booking: Booking): Promise<void> {
|
||||
/**
|
||||
* Reserve capacity for a commercial booking on a specific train and open its
|
||||
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
|
||||
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
|
||||
* which is all keyed off `booking.trainScheduleId`, can find the train — with
|
||||
* day-level pooling the booking arrives here with `trainScheduleId` still null,
|
||||
* so the engine sets it as it picks the train.
|
||||
*/
|
||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
selectedForBatchAt: now,
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Expire an unpaid reservation and free its capacity. */
|
||||
/**
|
||||
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||
*/
|
||||
private async expire(booking: Booking): Promise<void> {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@ export class BookingNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-facing warning when a pooled booking fits no train on its chosen day.
|
||||
* It stays pending and is retried next batch; staff can add capacity or pin it
|
||||
* to a train manually. Mirrors {@link scheduleFull} — no customer notification.
|
||||
*/
|
||||
unplaced(b: Booking, day: string): void {
|
||||
this.logger.warn(
|
||||
`UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
|
||||
);
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableDaysQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("available-days")
|
||||
// No staff guard: customers hit this while creating a booking to find which
|
||||
// DAYS have a departure on their route. Day-level pooling — no capacity is
|
||||
// returned, only the list of bookable days.
|
||||
@ApiOperation({
|
||||
summary: "Distinct days with an OPEN same-route departure (day-level pool)",
|
||||
})
|
||||
getAvailableDays(@Query() query: AvailableDaysQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableDays(
|
||||
query.originYardId,
|
||||
query.destinationYardId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("container/eligible-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List eligible container bookings" })
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
} from './booking-batch.constants';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
|
||||
) {}
|
||||
|
||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||
// resolving the schedule's route + day and filtering on the day instead.
|
||||
let day: string | undefined;
|
||||
let originStationId = query.originStationId;
|
||||
let destinationStationId = query.destinationStationId;
|
||||
if (query.trainScheduleId) {
|
||||
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
|
||||
if (schedule?.scheduledDepartureDate) {
|
||||
day = eatDay(schedule.scheduledDepartureDate);
|
||||
originStationId = originStationId ?? schedule.originStationId;
|
||||
destinationStationId = destinationStationId ?? schedule.destinationStationId;
|
||||
}
|
||||
}
|
||||
|
||||
const bookings = await this.bookingsRepository.findEligibleForScheduling({
|
||||
freightType: query.freightType,
|
||||
originStationId: query.originStationId,
|
||||
destinationStationId: query.destinationStationId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
schedulingStatus: query.schedulingStatus,
|
||||
trainScheduleId: query.trainScheduleId,
|
||||
day,
|
||||
});
|
||||
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
||||
}
|
||||
@@ -721,27 +738,37 @@ export class TrainSchedulingService {
|
||||
: null;
|
||||
|
||||
if (route) {
|
||||
const origin = route.originYard;
|
||||
const destination = route.destinationYard;
|
||||
// `route.milestones` is the complete ordered corridor and already includes
|
||||
// the origin (first) and destination (last) yards — `route.originYardId`
|
||||
// and `route.destinationYardId` are derived from them. Use the milestones
|
||||
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
|
||||
const milestones = [...(route.milestones ?? [])].sort(
|
||||
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
|
||||
);
|
||||
|
||||
if (milestones.length > 0) {
|
||||
milestones.forEach((m, i) =>
|
||||
stations.push({
|
||||
sequenceNo: i,
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||
code: m.yard?.code ?? '',
|
||||
}),
|
||||
);
|
||||
return stations;
|
||||
}
|
||||
|
||||
// Route with no milestones recorded — fall back to its origin/destination.
|
||||
const origin = route.originYard;
|
||||
const destination = route.destinationYard;
|
||||
stations.push({
|
||||
sequenceNo: 0,
|
||||
yardId: route.originYardId,
|
||||
label: origin?.label ?? origin?.code ?? 'Origin',
|
||||
code: origin?.code ?? '',
|
||||
});
|
||||
milestones.forEach((m, i) =>
|
||||
stations.push({
|
||||
sequenceNo: i + 1,
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||
code: m.yard?.code ?? '',
|
||||
}),
|
||||
);
|
||||
stations.push({
|
||||
sequenceNo: milestones.length + 1,
|
||||
sequenceNo: 1,
|
||||
yardId: route.destinationYardId,
|
||||
label: destination?.label ?? destination?.code ?? 'Destination',
|
||||
code: destination?.code ?? '',
|
||||
@@ -775,8 +802,16 @@ export class TrainSchedulingService {
|
||||
|
||||
const stations = await this.buildScheduleStations(schedule);
|
||||
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||
|
||||
// Resolve each checkpoint's position by its yard against the canonical
|
||||
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
|
||||
// under an older station numbering still line up with the current stations.
|
||||
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
|
||||
const resolvedSeq = (e: TrainCheckpointEvent) =>
|
||||
seqByYard.get(e.yardId) ?? e.sequenceNo;
|
||||
|
||||
const currentSequenceNo = events.length
|
||||
? Math.max(...events.map((e) => e.sequenceNo))
|
||||
? Math.max(...events.map(resolvedSeq))
|
||||
: -1;
|
||||
|
||||
return {
|
||||
@@ -790,13 +825,19 @@ export class TrainSchedulingService {
|
||||
actualArrivalAt: schedule.actualArrivalAt
|
||||
? schedule.actualArrivalAt.toISOString()
|
||||
: null,
|
||||
scheduledDepartureAt: schedule.scheduledDepartureDate
|
||||
? schedule.scheduledDepartureDate.toISOString()
|
||||
: null,
|
||||
scheduledArrivalAt: schedule.scheduledArrivalDate
|
||||
? schedule.scheduledArrivalDate.toISOString()
|
||||
: null,
|
||||
origin: stations[0]?.label ?? null,
|
||||
destination: stations[stations.length - 1]?.label ?? null,
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints: events.map((e) => ({
|
||||
id: e.id,
|
||||
sequenceNo: e.sequenceNo,
|
||||
sequenceNo: resolvedSeq(e),
|
||||
yardId: e.yardId,
|
||||
label: e.yard?.label ?? e.yard?.code ?? null,
|
||||
kind: e.kind,
|
||||
@@ -890,6 +931,19 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = $2,
|
||||
scheduling_status = $3
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = b.id
|
||||
AND tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
|
||||
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
||||
);
|
||||
|
||||
if (schedule.trainSet?.locomotiveId) {
|
||||
const loco = await manager
|
||||
.getRepository(Locomotive)
|
||||
@@ -1965,6 +2019,33 @@ export class TrainSchedulingService {
|
||||
return filteredSchedules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
|
||||
* departure on the route. Customers pick a DAY (not a train) — so this returns
|
||||
* only the day strings, no capacity, counts or train info.
|
||||
*/
|
||||
async getAvailableDays(
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): Promise<{ days: string[] }> {
|
||||
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
|
||||
}
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||
async existsOpenScheduleOnRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<boolean> {
|
||||
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
|
||||
return days.includes(day);
|
||||
}
|
||||
|
||||
private async mapScheduleDetail(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { Entity, Column, Index } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
export enum VehicleType {
|
||||
@@ -26,34 +26,39 @@ export enum VehicleStatus {
|
||||
}
|
||||
|
||||
@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, nullable: true })
|
||||
plateNumber?: string;
|
||||
@Column({ name: 'plate_number', unique: true })
|
||||
plateNumber!: string;
|
||||
|
||||
@Column({ name: 'registration_number', unique: true, nullable: true })
|
||||
registrationNumber?: string;
|
||||
@Column({ name: 'registration_number', unique: true })
|
||||
registrationNumber!: string;
|
||||
|
||||
@Column({ name: 'vehicle_type', type: 'varchar', nullable: true })
|
||||
vehicleType?: VehicleType;
|
||||
@Column({ name: 'vehicle_type', type: 'varchar' })
|
||||
vehicleType!: VehicleType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
manufacturer?: string;
|
||||
@Column()
|
||||
manufacturer!: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
model?: string;
|
||||
@Column()
|
||||
model!: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
year?: number;
|
||||
@Column()
|
||||
year!: number;
|
||||
|
||||
@Column({ name: 'fuel_type', type: 'varchar', nullable: true })
|
||||
fuelType?: FuelType;
|
||||
@Column({ name: 'fuel_type', type: 'varchar' })
|
||||
fuelType!: FuelType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
capacity?: number;
|
||||
@Column()
|
||||
capacity!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true })
|
||||
status?: VehicleStatus;
|
||||
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
|
||||
status!: VehicleStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
description!: string | null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -67,13 +67,12 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
|
||||
};
|
||||
}
|
||||
|
||||
async createVehicle(vehicleData: any): Promise<Vehicle> {
|
||||
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
|
||||
const vehicle = this.repository.create(vehicleData);
|
||||
const vehicles = await this.repository.save(vehicle);
|
||||
return vehicles?.[0] as Vehicle;
|
||||
return this.repository.save(vehicle);
|
||||
}
|
||||
|
||||
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
|
||||
return (await this.repository.save(vehicle)) as Vehicle;
|
||||
return this.repository.save(vehicle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, IsArray, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/** Bulk-mark received inventory items as inspection PASSED. */
|
||||
export class BulkInspectDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('all', { each: true })
|
||||
inventoryIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Inspection type label (e.g. ORIGIN_INSPECTION).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inspectionType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remarks?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inspectedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
|
||||
export class BulkReceiveDto {
|
||||
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
|
||||
@IsIn(['IMPORT', 'EXPORT'])
|
||||
direction!: 'IMPORT' | 'EXPORT';
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('all', { each: true })
|
||||
bookingIds!: string[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/** Proof of delivery captured when import goods are handed over to the customer. */
|
||||
export class DeliverInventoryDto {
|
||||
@ApiProperty({ description: 'Name of the person who received the goods' })
|
||||
@IsString()
|
||||
receiverName!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'When the goods were delivered (defaults to now)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
deliveredAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Delivery remarks / notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remarks?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/** Records a DO / release order being sent to the customer for import pickup. */
|
||||
export class ReleaseOrderDto {
|
||||
@ApiPropertyOptional({ description: 'DO / release order reference number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Release date (defaults to now)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
releaseDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -3,12 +3,16 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAREHOUSE_ACTIVITY_TYPES = [
|
||||
'INVENTORY_RECEIVED',
|
||||
'INVENTORY_UNLOADED',
|
||||
'INVENTORY_STORED',
|
||||
'INVENTORY_MOVED',
|
||||
'INVENTORY_RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'INVENTORY_LOADED',
|
||||
'INVENTORY_DISPATCHED',
|
||||
'READY_FOR_PICKUP',
|
||||
'INVENTORY_RELEASED',
|
||||
'INVENTORY_DELIVERED',
|
||||
] as const;
|
||||
export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number];
|
||||
|
||||
|
||||
@@ -8,26 +8,40 @@ 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
|
||||
// Lifecycle. Supersedes the Batch 1 set
|
||||
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
|
||||
// After RECEIVED + inspection (PASSED), the flow branches by booking trade direction:
|
||||
// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED
|
||||
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
|
||||
export const WAREHOUSE_INVENTORY_STATUSES = [
|
||||
'UNLOADED',
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
'READY_FOR_PICKUP',
|
||||
'DELIVERED',
|
||||
] 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'],
|
||||
// UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected.
|
||||
// Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection.
|
||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
STORED: ['RESERVED'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
DISPATCHED: [],
|
||||
// Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched
|
||||
// out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects
|
||||
// it / customs or inspection hold / operator chooses to store.
|
||||
READY_FOR_PICKUP: ['DELIVERED', 'STORED', 'DISPATCHED'],
|
||||
DELIVERED: [],
|
||||
};
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
|
||||
@@ -104,6 +118,10 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
// Batch 8: when the goods were unloaded off the arrived train (before storage/inspection).
|
||||
@Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true })
|
||||
unloadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
|
||||
storedAt?: Date | null;
|
||||
|
||||
@@ -135,6 +153,14 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'release_date', type: 'timestamptz', nullable: true })
|
||||
releaseDate?: Date | null;
|
||||
|
||||
// Import branch: reference of the DO / release order sent to the customer.
|
||||
@Column({ name: 'release_order_reference', type: 'varchar', length: 100, nullable: true })
|
||||
releaseOrderReference?: string | null;
|
||||
|
||||
// Import branch: when the goods were handed over to the customer (proof of delivery).
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt?: Date | null;
|
||||
|
||||
@Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true })
|
||||
gateClearedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Facility } from '../../facilities/entities/facility.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
@@ -63,6 +63,7 @@ export class Warehouse extends BaseEntity {
|
||||
facilityId?: string | null;
|
||||
|
||||
@ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true })
|
||||
@JoinColumn({ name: 'facility_id' })
|
||||
facility?: Facility | null;
|
||||
|
||||
@OneToMany(() => WarehouseYard, (yard) => yard.warehouse)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
|
||||
/**
|
||||
* READ-ONLY view into the train-scheduling / wagons domain for the warehouse module.
|
||||
*
|
||||
@@ -9,6 +11,32 @@ import { DataSource } from 'typeorm';
|
||||
* It is intentionally decoupled (raw SQL) so it does not import the scheduling
|
||||
* services/entities and cannot accidentally write to them.
|
||||
*/
|
||||
export interface ImportTrainRow {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
arrivalTime: string | null;
|
||||
totalBookings: number;
|
||||
totalContainers: number;
|
||||
totalCargoes: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ImportTrainItemRow {
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
lastMileRequested: boolean;
|
||||
pickupOption: string;
|
||||
}
|
||||
export interface WagonView {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
@@ -115,4 +143,81 @@ export class SchedulingReadFacade {
|
||||
departureStatus: schedule?.status ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ARRIVED train schedules whose route is IMPORT (origin country = Djibouti), with per-train
|
||||
* booking/container/cargo counts. Direction is derived from the origin/destination station
|
||||
* countries (route-based), so EXPORT/DOMESTIC trains never appear. Read-only.
|
||||
*/
|
||||
async importArriveQueue(): Promise<ImportTrainRow[]> {
|
||||
const rows: Array<
|
||||
ImportTrainRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS "scheduleId",
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
ts.status,
|
||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings",
|
||||
(SELECT count(*) FROM freight.containers c
|
||||
JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL
|
||||
WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers",
|
||||
(SELECT count(*) FROM freight.cargoes cg
|
||||
JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL
|
||||
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status = 'ARRIVED'
|
||||
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`,
|
||||
);
|
||||
|
||||
return rows
|
||||
.filter(
|
||||
(r) =>
|
||||
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
|
||||
)
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
|
||||
...rest,
|
||||
totalBookings: Number(rest.totalBookings) || 0,
|
||||
totalContainers: Number(rest.totalContainers) || 0,
|
||||
totalCargoes: Number(rest.totalCargoes) || 0,
|
||||
route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */
|
||||
async importTrainDetail(scheduleId: string): Promise<ImportTrainItemRow[]> {
|
||||
const rows: ImportTrainItemRow[] = await this.dataSource.query(
|
||||
`SELECT b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customerName",
|
||||
(SELECT c.container_number FROM freight.containers c
|
||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
COALESCE(inv.status, b.status) AS "currentStatus",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
||||
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
ORDER BY b.reference ASC NULLS LAST`,
|
||||
[scheduleId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,17 +78,13 @@ export class WarehouseAllocationService {
|
||||
/** 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;
|
||||
if (!rule) return null;
|
||||
|
||||
// Resolve yard (by rule code, else first available yard with a zone).
|
||||
// Resolve yard by rule code.
|
||||
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] : [],
|
||||
`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`,
|
||||
[rule.targetYardCode],
|
||||
);
|
||||
if (!yard) return null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
@@ -8,11 +8,18 @@ export interface WarehouseDashboard {
|
||||
totalWarehouses: number;
|
||||
totalInventory: number;
|
||||
receivedToday: number;
|
||||
// Inspection gate
|
||||
awaitingInspection: number;
|
||||
inspected: number;
|
||||
// Export branch
|
||||
stored: number;
|
||||
reserved: number;
|
||||
readyForLoading: number;
|
||||
loaded: number;
|
||||
dispatched: number;
|
||||
// Import branch
|
||||
readyForPickup: number;
|
||||
delivered: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -26,15 +33,31 @@ export class WarehouseDashboardService {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] =
|
||||
await Promise.all([
|
||||
const [
|
||||
totalWarehouses,
|
||||
totalInventory,
|
||||
awaitingInspection,
|
||||
inspected,
|
||||
stored,
|
||||
reserved,
|
||||
readyForLoading,
|
||||
loaded,
|
||||
dispatched,
|
||||
readyForPickup,
|
||||
delivered,
|
||||
receivedToday,
|
||||
] = await Promise.all([
|
||||
warehouseRepo.count(),
|
||||
inventoryRepo.count(),
|
||||
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
||||
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
|
||||
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.count({ where: { status: 'READY_FOR_PICKUP' } }),
|
||||
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
|
||||
inventoryRepo
|
||||
.createQueryBuilder('inv')
|
||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||
@@ -45,11 +68,15 @@ export class WarehouseDashboardService {
|
||||
totalWarehouses,
|
||||
totalInventory,
|
||||
receivedToday,
|
||||
awaitingInspection,
|
||||
inspected,
|
||||
stored,
|
||||
reserved,
|
||||
readyForLoading,
|
||||
loaded,
|
||||
dispatched,
|
||||
readyForPickup,
|
||||
delivered,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class WarehouseInspectionService {
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
/** Create an inspection report for an inventory item and sync its inspectionStatus. */
|
||||
/** Create or update the 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 } });
|
||||
@@ -29,8 +29,9 @@ export class WarehouseInspectionService {
|
||||
const expected = dto.expectedWeight ?? null;
|
||||
const actual = dto.actualWeight ?? null;
|
||||
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
||||
const inspectedAt = new Date();
|
||||
|
||||
const report = await this.inspectionRepository.create({
|
||||
const payload = {
|
||||
inventoryId,
|
||||
bookingId: inventory.bookingId ?? null,
|
||||
reportType: dto.reportType,
|
||||
@@ -46,13 +47,27 @@ export class WarehouseInspectionService {
|
||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||
remarks: dto.remarks ?? null,
|
||||
inspectedById: dto.inspectedById ?? null,
|
||||
inspectedAt: new Date(),
|
||||
inspectedAt,
|
||||
};
|
||||
|
||||
const [existingReport] = await this.inspectionRepository.findAll({
|
||||
where: { inventoryId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
let report: WarehouseInspectionReport;
|
||||
if (existingReport) {
|
||||
await this.inspectionRepository.update(existingReport.id, payload);
|
||||
report = await this.findById(existingReport.id);
|
||||
} else {
|
||||
report = await this.inspectionRepository.create(payload);
|
||||
}
|
||||
|
||||
// Mirror the latest outcome onto the inventory item so loading rules can read it.
|
||||
await inventoryRepo.update(inventoryId, {
|
||||
inspectionStatus: dto.inspectionStatus,
|
||||
inspectedAt: new Date(),
|
||||
inspectedAt,
|
||||
});
|
||||
|
||||
return report;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
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 { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
@@ -56,6 +61,49 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.autoLoadReady();
|
||||
}
|
||||
|
||||
@Get('eligible-bookings')
|
||||
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
|
||||
eligibleBookings(@Query('direction') direction?: string) {
|
||||
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
|
||||
return this.inventoryService.eligibleBookings(dir);
|
||||
}
|
||||
|
||||
@Post('receive-bulk')
|
||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||
receiveBulk(@Body() dto: BulkReceiveDto) {
|
||||
return this.inventoryService.bulkReceive(dto);
|
||||
}
|
||||
|
||||
@Post('load-passed-export')
|
||||
@ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' })
|
||||
loadPassedExport(@Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.loadPassedExport(performedBy);
|
||||
}
|
||||
|
||||
@Get('ready-to-load-export')
|
||||
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
|
||||
readyToLoadExport() {
|
||||
return this.inventoryService.readyToLoadExport();
|
||||
}
|
||||
|
||||
@Get('loaded-export')
|
||||
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
||||
loadedExport() {
|
||||
return this.inventoryService.loadedExport();
|
||||
}
|
||||
|
||||
@Post('bulk-dispatch-export')
|
||||
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
||||
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
|
||||
}
|
||||
|
||||
@Post('bulk-mark-inspected')
|
||||
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
|
||||
bulkMarkInspected(@Body() dto: BulkInspectDto) {
|
||||
return this.inventoryService.bulkMarkInspected(dto);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/unload')
|
||||
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
|
||||
unloadBooking(
|
||||
@@ -71,6 +119,36 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.gateClearance(id, performedBy);
|
||||
}
|
||||
|
||||
@Get('import/arrive-queue')
|
||||
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
|
||||
importArriveQueue() {
|
||||
return this.scheduling.importArriveQueue();
|
||||
}
|
||||
|
||||
@Get('import/trains/:scheduleId/items')
|
||||
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
|
||||
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.scheduling.importTrainDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Post('import/auto-unload-arrived-bookings')
|
||||
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
|
||||
autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
|
||||
}
|
||||
|
||||
@Get('import/unloaded-queue')
|
||||
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
|
||||
importUnloadedQueue() {
|
||||
return this.inventoryService.importUnloadedQueue();
|
||||
}
|
||||
|
||||
@Get('import/pickup-ready-queue')
|
||||
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
|
||||
importPickupReadyQueue() {
|
||||
return this.inventoryService.importPickupReadyQueue();
|
||||
}
|
||||
|
||||
@Get('loadable-wagons')
|
||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||
loadableWagons() {
|
||||
@@ -137,6 +215,34 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.load(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-pickup')
|
||||
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
||||
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForPickup(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/release')
|
||||
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
|
||||
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
|
||||
return this.inventoryService.release(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/release-document')
|
||||
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
|
||||
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
return this.inventoryService.deliver(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/dispatch')
|
||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,12 @@ export class WarehouseYardsController {
|
||||
private readonly zonesService: WarehouseZonesService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||
findAll() {
|
||||
return this.yardsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -13,6 +13,13 @@ export class WarehouseYardsService {
|
||||
private readonly warehousesService: WarehousesService,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
relations: { warehouse: true, zones: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
where: { warehouseId },
|
||||
|
||||
@@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all warehouse zones' })
|
||||
findAll() {
|
||||
return this.zonesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -13,6 +13,13 @@ export class WarehouseZonesService {
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<WarehouseZone[]> {
|
||||
return this.zonesRepository.findAll({
|
||||
relations: { yard: { warehouse: true } },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
||||
return this.zonesRepository.findAll({
|
||||
where: { yardId },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
@@ -100,6 +101,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInvoiceService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
SchedulingReadFacade,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [
|
||||
WarehousesService,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindManyOptions, ILike } from 'typeorm';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindManyOptions, ILike, QueryFailedError } from 'typeorm';
|
||||
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
@@ -49,7 +49,8 @@ export class WarehousesService {
|
||||
async create(dto: CreateWarehouseDto): Promise<Warehouse> {
|
||||
await this.assertCodeUnique(dto.code.trim());
|
||||
|
||||
return this.warehousesRepository.create({
|
||||
try {
|
||||
return await this.warehousesRepository.create({
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
@@ -66,6 +67,9 @@ export class WarehousesService {
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.mapDbError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseDto): Promise<Warehouse> {
|
||||
@@ -77,7 +81,9 @@ export class WarehousesService {
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.warehousesRepository.update(id, {
|
||||
let updated;
|
||||
try {
|
||||
updated = await this.warehousesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
@@ -91,6 +97,9 @@ export class WarehousesService {
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
} catch (error) {
|
||||
this.mapDbError(error);
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Warehouse ${id} not found`);
|
||||
@@ -99,6 +108,21 @@ export class WarehousesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
|
||||
private mapDbError(error: unknown): never {
|
||||
if (error instanceof QueryFailedError) {
|
||||
const driver = (error as QueryFailedError & { driverError?: { code?: string; detail?: string } }).driverError;
|
||||
if (driver?.code === '23503') {
|
||||
throw new BadRequestException('Selected facility does not exist.');
|
||||
}
|
||||
if (driver?.code === '22001') {
|
||||
throw new BadRequestException('A field is too long (code max 40, name max 160 characters).');
|
||||
}
|
||||
throw new BadRequestException(driver?.detail ?? error.message ?? 'Invalid warehouse data.');
|
||||
}
|
||||
throw error as Error;
|
||||
}
|
||||
|
||||
private async assertCodeUnique(code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.warehousesRepository.findAll({ where: { code } });
|
||||
|
||||
|
||||
145
apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts
Normal file
145
apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
|
||||
const SEED_REFS = ['SEED-B5-EXP-001', 'SEED-B5-EXP-002', 'SEED-B5-EXP-003'];
|
||||
|
||||
const SEEDS = [
|
||||
{ ref: 'SEED-B5-EXP-001', weight: 5000, notes: 'Electronics export cargo' },
|
||||
{ ref: 'SEED-B5-EXP-002', weight: 8500, notes: 'Textile export cargo' },
|
||||
{ ref: 'SEED-B5-EXP-003', weight: 3200, notes: 'Coffee export cargo' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Seeds 3 EXPORT+PAID bookings with READY_FOR_LOADING + inspection PASSED inventory
|
||||
* so the Batch 5 "Ready To Load" tab has visible rows to test against.
|
||||
*
|
||||
* Origin: any Ethiopian yard (route-based direction = EXPORT when dest = Djibouti)
|
||||
* Destination: any Djiboutian yard
|
||||
* Uses the INDODE_OPEN warehouse created by IndodeFacilitySeeder.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Batch5TestDataSeeder {
|
||||
private readonly logger = new Logger(Batch5TestDataSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
|
||||
const existing = await bookingRepo.findOne({ where: { reference: SEED_REFS[0] } });
|
||||
if (existing) {
|
||||
this.logger.log('Batch 5 test data already seeded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const yardRepo = this.dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
|
||||
// Find Ethiopian origin yard and Djiboutian destination yard.
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'ADDIS_ABABA' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
const destYard =
|
||||
(await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
|
||||
if (!originYard || !destYard) {
|
||||
this.logger.warn(
|
||||
`Required yards not found (origin=${originYard?.code ?? 'none'}, dest=${destYard?.code ?? 'none'}); skipping Batch 5 seed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find any active service type (bookings require one).
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
if (!serviceType) {
|
||||
this.logger.warn('No service type found; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find INDODE warehouse.
|
||||
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
|
||||
if (!warehouse) {
|
||||
this.logger.warn('INDODE_OPEN warehouse not found; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } });
|
||||
if (!warehouseYard) {
|
||||
this.logger.warn('No warehouse yard found for INDODE_OPEN; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } });
|
||||
if (!warehouseZone) {
|
||||
this.logger.warn('No warehouse zone found; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const seed of SEEDS) {
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference: seed.ref,
|
||||
originYardId: originYard.id,
|
||||
destinationYardId: destYard.id,
|
||||
serviceTypeId: serviceType.id,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: now,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: seed.weight,
|
||||
cargoFreeText: seed.notes,
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
bookingId: booking.id,
|
||||
warehouseId: warehouse.id,
|
||||
yardId: warehouseYard.id,
|
||||
zoneId: warehouseZone.id,
|
||||
status: 'READY_FOR_LOADING',
|
||||
inspectionStatus: 'PASSED',
|
||||
inspectedAt: new Date(now.getTime() - 3600 * 1000),
|
||||
quantity: 1,
|
||||
weight: seed.weight,
|
||||
arrivedAt: new Date(now.getTime() - 7200 * 1000),
|
||||
readyForLoadingAt: new Date(now.getTime() - 1800 * 1000),
|
||||
notes: `[SEED-B5] ${seed.notes}`,
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Seeded ${seed.ref} → READY_FOR_LOADING + PASSED`);
|
||||
}
|
||||
|
||||
this.logger.log('✅ Batch 5 Ready-To-Load test data seeded successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Batch5TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts
Normal file
103
apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
|
||||
/**
|
||||
* Seeds two ARRIVED train schedules so the Import Arrive Queue (Batch 7) is demonstrable:
|
||||
* - SEED-IMP-TRAIN-01: DJIB_PORT → MOJO (IMPORT) linked to booking SEED-IMP-001 → SHOWS
|
||||
* - SEED-EXP-TRAIN-01: MOJO → DJIB_PORT (EXPORT) linked to booking SEED-EXP-001 → must NOT show
|
||||
*
|
||||
* Read-only train-schedule SERVICE logic is untouched; this only inserts fixture rows.
|
||||
* Idempotent: guards on the import train number.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Batch7TestDataSeeder {
|
||||
private readonly logger = new Logger(Batch7TestDataSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
|
||||
const existing = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } });
|
||||
if (existing) {
|
||||
this.logger.log('Batch 7 test data already seeded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
const locoRepo = this.dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = this.dataSource.getRepository(TrainSet);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const importBooking = await bookingRepo.findOne({ where: { reference: 'SEED-IMP-001' } });
|
||||
const exportBooking = await bookingRepo.findOne({ where: { reference: 'SEED-EXP-001' } });
|
||||
if (!importBooking) {
|
||||
this.logger.warn('SEED-IMP-001 booking not found; skipping Batch 7 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
// One shared locomotive is fine — train_set.locomotive_id is not unique.
|
||||
const loco =
|
||||
(await locoRepo.findOne({ where: { code: 'SEED-LOCO-01' } })) ??
|
||||
(await locoRepo.save(
|
||||
locoRepo.create({ code: 'SEED-LOCO-01', name: 'Seed Locomotive', maxPullWeightTons: 4000 }),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const arrival = new Date(now.getTime() - 3600 * 1000);
|
||||
const departure = new Date(now.getTime() - 6 * 3600 * 1000);
|
||||
|
||||
const makeArrivedTrain = async (
|
||||
trainNumber: string,
|
||||
booking: Booking,
|
||||
): Promise<void> => {
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: loco.id,
|
||||
totalWeightTons: 500,
|
||||
totalLengthMeters: 300,
|
||||
wagonCount: 10,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber,
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: booking.id }),
|
||||
);
|
||||
|
||||
this.logger.log(`Seeded arrived train ${trainNumber} → booking ${booking.reference}`);
|
||||
};
|
||||
|
||||
await makeArrivedTrain('SEED-IMP-TRAIN-01', importBooking);
|
||||
if (exportBooking) {
|
||||
await makeArrivedTrain('SEED-EXP-TRAIN-01', exportBooking);
|
||||
}
|
||||
|
||||
this.logger.log('✅ Batch 7 arrive-queue test data seeded successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Batch7TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts
Normal file
51
apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
|
||||
/**
|
||||
* Makes the Batch 7 seed import train demonstrable for Batch 8: a booking riding an ARRIVED
|
||||
* train is IN_TRANSIT until unloaded, so flip the seed import train's assigned bookings to
|
||||
* IN_TRANSIT (an unload-eligible status). Idempotent — re-applying IN_TRANSIT is a no-op.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Batch8TestDataSeeder {
|
||||
private readonly logger = new Logger(Batch8TestDataSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
|
||||
const train = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } });
|
||||
if (!train) {
|
||||
this.logger.log('SEED-IMP-TRAIN-01 not found; skipping Batch 8 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const links = await scheduleBookingRepo.find({ where: { trainScheduleId: train.id } });
|
||||
let updated = 0;
|
||||
for (const link of links) {
|
||||
const booking = await bookingRepo.findOne({ where: { id: link.bookingId } });
|
||||
if (!booking || booking.status === 'IN_TRANSIT') continue;
|
||||
await bookingRepo.update(booking.id, { status: 'IN_TRANSIT' });
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
this.logger.log(`✅ Batch 8: set ${updated} import train booking(s) to IN_TRANSIT (unload-eligible)`);
|
||||
} else {
|
||||
this.logger.log('Batch 8: import train bookings already IN_TRANSIT, skipping');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Batch8TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,13 @@ export class DemoFreightDataSeeder {
|
||||
|
||||
async run() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
// Demo freight data (wagons + approval rules) disabled — keep only the
|
||||
// 4 staff users. The seeders are retained for easy re-enabling; flip
|
||||
// SEED_DEMO_FREIGHT_DATA=true to run them.
|
||||
if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') {
|
||||
await this.seedWagons(manager);
|
||||
await this.seedApprovalRules(manager);
|
||||
}
|
||||
await this.seedStaffUsers(manager);
|
||||
});
|
||||
}
|
||||
|
||||
271
apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
Normal file
271
apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
|
||||
/**
|
||||
* One coherent warehouse dataset so EVERY queue/tab shows representative data:
|
||||
* Export → Receive Queue : PAID export bookings, not yet received
|
||||
* Export → Ready To Load : EXPORT inventory READY_FOR_LOADING + inspection PASSED
|
||||
* Export → Loaded/Dispatch : EXPORT inventory LOADED
|
||||
* Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory)
|
||||
* Import → Unloaded Queue : UNLOADED import inventory
|
||||
* Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED)
|
||||
*
|
||||
* Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it
|
||||
* never collides with other seeders. To repopulate after items are walked through their lifecycle,
|
||||
* delete the WH-DEMO-* bookings (cascades) and reboot.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseDemoSeeder {
|
||||
private readonly logger = new Logger(WarehouseDemoSeeder.name);
|
||||
private readonly SENTINEL = 'WH-DEMO-RCV-1';
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
if (await bookingRepo.findOne({ where: { reference: this.SENTINEL } })) {
|
||||
this.logger.log('Warehouse demo data already seeded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const yardRepo = this.dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
|
||||
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const whYardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||
const whZoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||
const invRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
|
||||
const djibYard =
|
||||
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const ethYard =
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
|
||||
if (!djibYard || !ethYard || !serviceType) {
|
||||
this.logger.warn(
|
||||
`Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
|
||||
const whYard = warehouse ? await whYardRepo.findOne({ where: { warehouseId: warehouse.id } }) : null;
|
||||
const whZone = whYard ? await whZoneRepo.findOne({ where: { yardId: whYard.id } }) : null;
|
||||
if (!warehouse || !whYard || !whZone) {
|
||||
this.logger.warn('INDODE_OPEN warehouse/yard/zone missing; skipping warehouse demo seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const ago = (mins: number) => new Date(now - mins * 60_000);
|
||||
|
||||
// EXPORT booking = Ethiopia → Djibouti; IMPORT booking = Djibouti → Ethiopia.
|
||||
const makeBooking = async (
|
||||
reference: string,
|
||||
direction: 'EXPORT' | 'IMPORT',
|
||||
status: string,
|
||||
weight: number,
|
||||
idx: number,
|
||||
): Promise<Booking> =>
|
||||
bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
...this.demoBookingDefaults(),
|
||||
reference,
|
||||
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
||||
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
|
||||
serviceTypeId: serviceType.id,
|
||||
status,
|
||||
paymentStatus: 'PAID',
|
||||
tradeDirection: direction,
|
||||
freightType: idx % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType ? null : `${direction} demo cargo ${idx}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
}),
|
||||
);
|
||||
|
||||
const makeInventory = async (
|
||||
booking: Booking,
|
||||
status: string,
|
||||
weight: number,
|
||||
extra: Partial<WarehouseInventory>,
|
||||
): Promise<void> => {
|
||||
await invRepo.save(
|
||||
invRepo.create({
|
||||
warehouseId: warehouse.id,
|
||||
yardId: whYard.id,
|
||||
zoneId: whZone.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: status as WarehouseInventory['status'],
|
||||
notes: '[WH-DEMO]',
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
let created = 0;
|
||||
|
||||
// 1) Export Receive Queue — 3 PAID export bookings, NO inventory.
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await makeBooking(`WH-DEMO-RCV-${i}`, 'EXPORT', 'PAID', 4000 + i * 500, i);
|
||||
created++;
|
||||
}
|
||||
|
||||
// 2) Export Ready To Load — EXPORT inventory READY_FOR_LOADING + PASSED.
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-RTL-${i}`, 'EXPORT', 'PAID', 6000 + i * 500, i);
|
||||
await makeInventory(b, 'READY_FOR_LOADING', 6000 + i * 500, {
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: ago(180),
|
||||
inspectedAt: ago(120),
|
||||
readyForLoadingAt: ago(60),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// 3) Export Loaded / Dispatch Queue — EXPORT inventory LOADED.
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-LOAD-${i}`, 'EXPORT', 'PAID', 7000 + i * 500, i);
|
||||
await makeInventory(b, 'LOADED', 7000 + i * 500, {
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: ago(240),
|
||||
inspectedAt: ago(180),
|
||||
readyForLoadingAt: ago(120),
|
||||
loadedAt: ago(30),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored).
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i);
|
||||
await makeInventory(b, 'UNLOADED', 5000 + i * 500, {
|
||||
arrivedAt: ago(90),
|
||||
unloadedAt: ago(45),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED).
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i);
|
||||
await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, {
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: ago(200),
|
||||
unloadedAt: ago(160),
|
||||
inspectedAt: ago(120),
|
||||
readyForPickupAt: ago(60),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet.
|
||||
await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360));
|
||||
created += 1;
|
||||
|
||||
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`WarehouseDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** An ARRIVED Djibouti→Ethiopia train with 3 IN_TRANSIT bookings (no inventory) for the Arrive Queue. */
|
||||
private async seedArrivedImportTrain(
|
||||
djibYard: Yard,
|
||||
ethYard: Yard,
|
||||
serviceType: ServiceType,
|
||||
cargoType: CargoType | null,
|
||||
arrival: Date,
|
||||
departure: Date,
|
||||
): Promise<void> {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
const locoRepo = this.dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = this.dataSource.getRepository(TrainSet);
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const loco =
|
||||
(await locoRepo.findOne({ where: { code: 'WH-DEMO-LOCO' } })) ??
|
||||
(await locoRepo.save(locoRepo.create({ code: 'WH-DEMO-LOCO', name: 'Demo Locomotive', maxPullWeightTons: 4000 })));
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: loco.id,
|
||||
totalWeightTons: 500,
|
||||
totalLengthMeters: 300,
|
||||
wagonCount: 10,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: djibYard.id,
|
||||
destinationStationId: ethYard.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: 'WH-DEMO-IMP-TRAIN',
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
...this.demoBookingDefaults(),
|
||||
reference: `WH-DEMO-ARR-${i}`,
|
||||
originYardId: djibYard.id,
|
||||
destinationYardId: ethYard.id,
|
||||
serviceTypeId: serviceType.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: i % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType ? null : `IMPORT arrive demo cargo ${i}`,
|
||||
cargoTotalWeightVgm: 5000 + i * 400,
|
||||
}),
|
||||
);
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: b.id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private demoBookingDefaults(): Partial<Booking> {
|
||||
return {
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,7 @@
|
||||
"preview": "vite preview --port 5183",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit",
|
||||
"build:user-management": "cd user-management-config && npm run build",
|
||||
"backoffice": "npm run build:user-management && nx serve @fhc-platform/backoffice",
|
||||
"backoffice:no-build": "nx serve @fhc-platform/backoffice"
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
@@ -22,7 +19,7 @@
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tria-plc/iamui-common": "1.1.2",
|
||||
"@tria-plc/iamui": "0.0.3",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -44,14 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||
@@ -69,7 +62,6 @@ import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
@@ -180,18 +172,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
{
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
{
|
||||
label: "Warehouse dashboard",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses/list",
|
||||
icon: <Container />,
|
||||
},
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
{
|
||||
label: "Warehouse Dashboard",
|
||||
@@ -248,34 +228,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
title: "Administration",
|
||||
items: [
|
||||
{
|
||||
label: "User management",
|
||||
href: "/dashboard/user-management",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
children: [
|
||||
{
|
||||
label: "Users",
|
||||
href: "/dashboard/user-management/users",
|
||||
},
|
||||
{
|
||||
label: "Employees",
|
||||
href: "/dashboard/user-management/employees",
|
||||
},
|
||||
{
|
||||
label: "Position Types",
|
||||
href: "/dashboard/user-management/position-types",
|
||||
},
|
||||
{
|
||||
label: "Permissions",
|
||||
href: "/dashboard/user-management/permissions",
|
||||
},
|
||||
{
|
||||
label: "Roles",
|
||||
href: "/dashboard/user-management/roles",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "File settings",
|
||||
href: "/dashboard/file-settings",
|
||||
@@ -376,16 +328,18 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path='/um/*' element={<UserManagementHostPage />} />
|
||||
<Route path="*" element={<Navigate to="/um" replace />} />
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route index element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
|
||||
@@ -404,27 +358,8 @@ const App = () => {
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={<TrainScheduleV2ListPage />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={<TrainScheduleV2DetailPage />}
|
||||
/>
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<FleetResourcePage />} />
|
||||
<Route path="trains" element={<FleetResourcePage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<FleetResourcePage />} />
|
||||
<Route path="containers" element={<FleetResourcePage />} />
|
||||
<Route path="cargoes" element={<FleetResourcePage />} />
|
||||
|
||||
<Route path="warehouse-dashboard" element={<Navigate to="/dashboard/warehouses" replace />} />
|
||||
<Route path="warehouses" element={<WarehouseDashboardPage />} />
|
||||
<Route path="warehouses/list" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
@@ -435,7 +370,6 @@ const App = () => {
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
@@ -594,6 +528,8 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
<Route
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
useGenerateInvoice,
|
||||
useInvoicesForInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
@@ -35,6 +37,9 @@ function fmtDate(iso: string | null) {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
||||
const configured = Boolean(fee.ruleId);
|
||||
@@ -51,7 +56,7 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
||||
{fee.amount.toLocaleString()} {fee.currency}
|
||||
{money(fee.amount, fee.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -63,7 +68,7 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
<Stack gap={4}>
|
||||
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
||||
<Row label="Free days" value={String(fee.freeDays)} />
|
||||
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
|
||||
<Row label="Rate / day" value={money(fee.ratePerDay, fee.currency)} />
|
||||
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
||||
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
@@ -99,7 +104,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
|
||||
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} — ${inv.totalAmount} ${inv.currency}` });
|
||||
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` });
|
||||
} catch (error) {
|
||||
const msg = extractErrorMessage(error);
|
||||
if (/no payable warehouse fee/i.test(msg)) {
|
||||
@@ -114,11 +119,22 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
|
||||
const handleGateClearance = async () => {
|
||||
if (!inventoryId) return;
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
await gateClear.mutateAsync(inventoryId);
|
||||
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
|
||||
const response = await gateClear.mutateAsync(inventoryId) as { data?: { booking?: { reference?: string | null }; bookingId?: string | null } };
|
||||
const releasedItem = response.data;
|
||||
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
|
||||
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
|
||||
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: opened
|
||||
? 'The release PDF opened in a browser tab.'
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
@@ -158,7 +174,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
|
||||
{money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { Upload } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
|
||||
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
|
||||
import {
|
||||
INSPECTION_REPORT_TYPES,
|
||||
INSPECTION_STATUSES,
|
||||
@@ -47,6 +47,7 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
|
||||
const { toast } = useToast();
|
||||
const createReport = useCreateInspectionReport();
|
||||
const uploadAttachments = useUploadInspectionAttachments();
|
||||
const reportsQuery = useInspectionReports(opened ? inventoryId ?? undefined : undefined);
|
||||
|
||||
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
|
||||
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
|
||||
@@ -76,6 +77,27 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const report = reportsQuery.data?.[0];
|
||||
if (!report) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
setReportType(report.reportType);
|
||||
setInspectionStatus(report.inspectionStatus);
|
||||
setHasDamage(report.hasDamage ?? false);
|
||||
setDamageDescription(report.damageDescription ?? '');
|
||||
setHasWeightLoss(report.hasWeightLoss ?? false);
|
||||
setExpectedWeight(report.expectedWeight == null ? '' : Number(report.expectedWeight));
|
||||
setActualWeight(report.actualWeight == null ? '' : Number(report.actualWeight));
|
||||
setHasMissingItems(report.hasMissingItems ?? false);
|
||||
setMissingItemsDescription(report.missingItemsDescription ?? '');
|
||||
setRemarks(report.remarks ?? '');
|
||||
setFiles([]);
|
||||
}, [opened, reportsQuery.data]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber } from './options';
|
||||
|
||||
interface InventoryDetailModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{value || '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
|
||||
{!item ? (
|
||||
<Text c="dimmed">No inventory item selected.</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Text size="lg" fw={800}>
|
||||
{item.booking?.reference ?? item.bookingId ?? item.id}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Inventory ID: {item.id}
|
||||
</Text>
|
||||
</Stack>
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</Group>
|
||||
|
||||
<Divider label="Location" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Warehouse" value={item.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : '-'} />
|
||||
<DetailRow label="Yard" value={item.yard ? `${item.yard.name} (${item.yard.code})` : '-'} />
|
||||
<DetailRow label="Zone" value={item.zone ? `${item.zone.name} (${item.zone.code})` : '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Booking & item" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
|
||||
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
|
||||
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
|
||||
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
|
||||
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
|
||||
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
|
||||
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
||||
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Lifecycle" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Inspection" value={<Badge variant="light">{item.inspectionStatus ?? 'Not inspected'}</Badge>} />
|
||||
<DetailRow label="Arrived" value={formatDate(item.arrivedAt)} />
|
||||
<DetailRow label="Stored" value={formatDate(item.storedAt)} />
|
||||
<DetailRow label="Reserved" value={formatDate(item.reservedAt)} />
|
||||
<DetailRow label="Inspected" value={formatDate(item.inspectedAt)} />
|
||||
<DetailRow label="Ready for loading" value={formatDate(item.readyForLoadingAt)} />
|
||||
<DetailRow label="Loaded" value={formatDate(item.loadedAt)} />
|
||||
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
|
||||
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
|
||||
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
|
||||
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
{item.notes && (
|
||||
<>
|
||||
<Divider label="Notes" labelPosition="left" />
|
||||
<Text size="sm">{item.notes}</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
|
||||
import type { InventoryInquiryResult } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber } from './options';
|
||||
|
||||
interface InventoryInquiryDetailModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
result: InventoryInquiryResult | null;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{value || '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function itemLabel(result: InventoryInquiryResult) {
|
||||
if (result.containerNumber) return `Container ${result.containerNumber}`;
|
||||
if (result.cargoType) return result.cargoType;
|
||||
if (result.cargoDescription) return result.cargoDescription;
|
||||
if (result.goodsId) return `Goods ${result.goodsId}`;
|
||||
return '-';
|
||||
}
|
||||
|
||||
export function InventoryInquiryDetailModal({ opened, onClose, result }: InventoryInquiryDetailModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inventory inquiry detail" centered size="xl">
|
||||
{!result ? (
|
||||
<Text c="dimmed">No inquiry result selected.</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Text size="lg" fw={800}>
|
||||
{result.bookingReference ?? result.bookingNumber ?? result.bookingId ?? result.id}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Inventory ID: {result.inventoryId ?? 'Not yet in warehouse inventory'}
|
||||
</Text>
|
||||
</Stack>
|
||||
{result.status ? (
|
||||
<InventoryStatusBadge status={result.status} />
|
||||
) : (
|
||||
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'}>
|
||||
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Divider label="Booking" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Booking reference" value={result.bookingReference ?? result.bookingNumber ?? '-'} />
|
||||
<DetailRow label="Booking status" value={result.bookingStatus ?? '-'} />
|
||||
<DetailRow label="Customer" value={result.customerName ?? '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Item" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Item" value={itemLabel(result)} />
|
||||
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(result.weight)} kg`} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Location" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Warehouse" value={result.warehouse ? `${result.warehouse.name} (${result.warehouse.code})` : '-'} />
|
||||
<DetailRow label="Yard" value={result.yard ? `${result.yard.name} (${result.yard.code})` : '-'} />
|
||||
<DetailRow label="Zone" value={result.zone ? `${result.zone.name} (${result.zone.code})` : '-'} />
|
||||
<DetailRow label="Current location" value={result.locationSummary ?? '-'} />
|
||||
<DetailRow label="Train" value={result.trainNumber ?? '-'} />
|
||||
<DetailRow label="Route" value={result.route ?? '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Dates" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Arrived" value={formatDate(result.arrivedAt)} />
|
||||
<DetailRow label="Ready for loading" value={formatDate(result.readyForLoadingAt)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useBulkMarkInspected,
|
||||
useDispatchInventory,
|
||||
useMarkReadyForLoading,
|
||||
useMarkReadyForPickup,
|
||||
useStoreInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
@@ -19,20 +23,24 @@ import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
isLoading?: boolean;
|
||||
/** Optional Last Mile action (Batch 8) — only shown for items whose booking requested door delivery. */
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
||||
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
|
||||
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
||||
const { toast } = useToast();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
@@ -42,6 +50,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
const readyMutation = useMarkReadyForLoading();
|
||||
const pickupMutation = useMarkReadyForPickup();
|
||||
const dispatchMutation = useDispatchInventory();
|
||||
const inspectMutation = useBulkMarkInspected();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const allSelected = items.length > 0 && selected.size === items.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleSelect = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
const toggleSelectAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
|
||||
|
||||
const markInspected = async () => {
|
||||
if (selected.size === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select at least one item' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as {
|
||||
data: { inspectedCount: number; skippedCount: number };
|
||||
};
|
||||
const r = res.data;
|
||||
toast({
|
||||
title: `${r.inspectedCount} marked inspected`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
});
|
||||
setSelected(new Set());
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
|
||||
setBusyId(item.id);
|
||||
@@ -55,10 +96,47 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReleaseDocument = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||
const blob = response.data;
|
||||
const filename = `release-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({ title: opened ? 'Release exit paper opened' : 'Release exit paper downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Release paper preview failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
const response = (await storeMutation.mutateAsync(item.id)) as { data: WarehouseInventoryItem };
|
||||
const stored = response.data;
|
||||
toast({
|
||||
title: 'Inventory stored',
|
||||
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
|
||||
switch (action) {
|
||||
case 'store':
|
||||
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
|
||||
return storeInventory(item);
|
||||
case 'reserve':
|
||||
setReserveItem(item);
|
||||
return;
|
||||
@@ -92,15 +170,41 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Selected: <b>{selected.size}</b>
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={inspectMutation.isPending}
|
||||
onClick={markInspected}
|
||||
>
|
||||
Mark Selected as Inspected
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<WarehouseInventoryTable
|
||||
items={items}
|
||||
busyId={busyId}
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onView={setViewItem}
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onLastMile={onLastMile}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<ReserveInventoryModal
|
||||
@@ -114,6 +218,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
onClose={() => setHistoryItem(null)}
|
||||
item={historyItem}
|
||||
/>
|
||||
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectItem)}
|
||||
onClose={() => setInspectItem(null)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,10 @@ import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useReleaseInventory } from '@/hooks/useWarehouses';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface ReleaseOrderModalProps {
|
||||
opened: boolean;
|
||||
@@ -17,6 +19,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useReleaseInventory();
|
||||
const [reference, setReference] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setReference(item?.releaseOrderReference ?? '');
|
||||
@@ -24,36 +27,54 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
|
||||
toast({ title: 'Release order issued' });
|
||||
const released = await releaseMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { reference: reference.trim() || undefined },
|
||||
});
|
||||
const releasedItem = released.data;
|
||||
setDownloading(true);
|
||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||
const blob = response.data;
|
||||
const filename = `release-${releasedItem.booking?.reference ?? releasedItem.bookingId ?? item.id}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: 'Release exit paper issued',
|
||||
description: opened
|
||||
? 'The PDF opened in a browser tab for printing or saving.'
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
|
||||
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Records the delivery order / release order sent to the customer. Once issued, the goods can be
|
||||
picked up and delivered.
|
||||
Creates the warehouse release document with booking, customer, cargo and location details. The
|
||||
printed paper authorizes the goods to leave the warehouse gate.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Release order reference"
|
||||
placeholder="e.g. DO-2026-001"
|
||||
label="Release document reference"
|
||||
placeholder="e.g. REL-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
|
||||
Issue release order
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Issue & view exit paper
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
|
||||
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
|
||||
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
@@ -31,56 +31,101 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{warehouses.map((warehouse) => (
|
||||
<Card key={warehouse.id} withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Card
|
||||
key={warehouse.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding={0}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
borderColor: 'var(--mantine-color-gray-2)',
|
||||
background: 'white',
|
||||
}}
|
||||
>
|
||||
<Box h={3} bg={warehouse.status === 'ACTIVE' ? 'green.5' : 'gray.4'} />
|
||||
<Stack gap="md" p="lg">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<div>
|
||||
<Text fw={700}>{warehouse.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
background: 'var(--mantine-color-green-0)',
|
||||
color: 'var(--mantine-color-green-7)',
|
||||
border: '1px solid var(--mantine-color-green-2)',
|
||||
}}
|
||||
>
|
||||
<Building2 size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={800} size="md" truncate>
|
||||
{warehouse.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={600} truncate>
|
||||
{warehouse.code}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6} align="flex-end">
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Stack gap={8}>
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<Building2 size={14} />
|
||||
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text>
|
||||
<Group gap={8} c="dimmed" wrap="nowrap">
|
||||
<Building2 size={15} />
|
||||
<Text size="sm" truncate>
|
||||
{stationNameById.get(warehouse.stationId)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{warehouse.locationName && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<MapPin size={14} />
|
||||
<Text size="sm">{warehouse.locationName}</Text>
|
||||
<Group gap={8} c="dimmed" wrap="nowrap">
|
||||
<MapPin size={15} />
|
||||
<Text size="sm" truncate>
|
||||
{warehouse.locationName}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
<Text size="sm">{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Containers
|
||||
</Text>
|
||||
<Text size="sm">{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
|
||||
<Stack gap="md">
|
||||
<CapacityRow
|
||||
icon={Weight}
|
||||
label="Weight"
|
||||
current={warehouse.currentWeight}
|
||||
capacity={warehouse.capacityWeight}
|
||||
/>
|
||||
<CapacityRow
|
||||
icon={Package}
|
||||
label="Containers"
|
||||
current={warehouse.currentContainers}
|
||||
capacity={warehouse.capacityContainers}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" gap="xs" mt="auto">
|
||||
<Tooltip label="View warehouse" withArrow>
|
||||
<ActionIcon variant="light" color="gray" onClick={() => onView(warehouse)} aria-label="View warehouse">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit warehouse" withArrow>
|
||||
<ActionIcon variant="light" color="orange" onClick={() => onEdit(warehouse)} aria-label="Edit warehouse">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -88,3 +133,40 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
const capacityPercent = (current?: number | null, capacity?: number | null) => {
|
||||
if (!capacity || capacity <= 0) return 0;
|
||||
return Math.min(100, Math.max(0, ((current ?? 0) / capacity) * 100));
|
||||
};
|
||||
|
||||
function CapacityRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
current,
|
||||
capacity,
|
||||
}: {
|
||||
icon: typeof Weight;
|
||||
label: string;
|
||||
current?: number | null;
|
||||
capacity?: number | null;
|
||||
}) {
|
||||
const percent = capacityPercent(current, capacity);
|
||||
const color = percent >= 90 ? 'red' : percent >= 70 ? 'orange' : 'green';
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={7} c="dimmed" wrap="nowrap">
|
||||
<Icon size={15} />
|
||||
<Text size="xs" fw={700} tt="uppercase">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatCapacity(current, capacity)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={percent} color={color} size="xs" radius="xl" bg="var(--mantine-color-gray-1)" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,15 +23,15 @@ interface WarehouseDashboardChartsProps {
|
||||
}
|
||||
|
||||
const ORANGE = '#f08c00';
|
||||
const GREEN = '#5bbf4a';
|
||||
const GREEN = '#22c55e'; // green from bookings
|
||||
|
||||
/** Inventory lifecycle status series — alternating orange / light green. */
|
||||
/** Inventory lifecycle status series — one distinct color per status (aligned with status badges). */
|
||||
const STATUS_SERIES = [
|
||||
{ key: 'stored', label: 'Stored', color: ORANGE },
|
||||
{ key: 'reserved', label: 'Reserved', color: GREEN },
|
||||
{ key: 'readyForLoading', label: 'Ready', color: ORANGE },
|
||||
{ key: 'loaded', label: 'Loaded', color: GREEN },
|
||||
{ key: 'dispatched', label: 'Dispatched', color: ORANGE },
|
||||
{ key: 'stored', label: 'Stored', color: '#228be6' }, // blue
|
||||
{ key: 'reserved', label: 'Reserved', color: '#ae3ec9' }, // grape
|
||||
{ key: 'readyForLoading', label: 'Ready', color: '#f08c00' }, // orange
|
||||
{ key: 'loaded', label: 'Loaded', color: '#12b886' }, // teal
|
||||
{ key: 'dispatched', label: 'Dispatched', color: GREEN }, // green (bookings)
|
||||
] as const;
|
||||
|
||||
type Granularity = 'week' | 'month' | 'year';
|
||||
@@ -157,8 +157,8 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
|
||||
outerRadius={95}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{statusData.map((entry, i) => (
|
||||
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
|
||||
{statusData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Stack, Table, Text } from '@mantine/core';
|
||||
import { ActionIcon, Badge, Stack, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
import type { InventoryInquiryResult } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
@@ -6,6 +7,7 @@ import { formatDate, formatNumber } from './options';
|
||||
|
||||
interface WarehouseInquiryTableProps {
|
||||
results: InventoryInquiryResult[];
|
||||
onView?: (result: InventoryInquiryResult) => void;
|
||||
}
|
||||
|
||||
const itemDescriptor = (result: InventoryInquiryResult) => {
|
||||
@@ -16,7 +18,7 @@ const itemDescriptor = (result: InventoryInquiryResult) => {
|
||||
return '—';
|
||||
};
|
||||
|
||||
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
||||
export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
@@ -36,11 +38,13 @@ export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
<Table.Th>Ready</Table.Th>
|
||||
{onView && <Table.Th ta="right">Actions</Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -48,7 +52,7 @@ export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
||||
<Table.Tr key={result.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{result.bookingNumber ?? result.bookingId.slice(0, 8)}
|
||||
{result.bookingReference ?? result.bookingNumber ?? result.bookingId?.slice(0, 8) ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{result.customerName ?? '—'}</Table.Td>
|
||||
@@ -66,7 +70,24 @@ export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
||||
<Table.Td>{result.yard?.name ?? '—'}</Table.Td>
|
||||
<Table.Td>{result.zone?.name ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{result.locationSummary ?? '-'}</Text>
|
||||
{result.trainNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{result.trainNumber}
|
||||
{result.route ? ` - ${result.route}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{result.status ? (
|
||||
<InventoryStatusBadge status={result.status} />
|
||||
) : (
|
||||
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'} size="sm">
|
||||
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
|
||||
<Table.Td>{formatNumber(result.weight)}</Table.Td>
|
||||
@@ -76,6 +97,15 @@ export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
|
||||
</Table.Td>
|
||||
{onView && (
|
||||
<Table.Td>
|
||||
<Tooltip label="View details" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(result)} ml="auto">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { getNextInventoryAction } from '@/types/warehouse';
|
||||
@@ -12,8 +12,18 @@ interface WarehouseInventoryTableProps {
|
||||
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
|
||||
onMove: (item: WarehouseInventoryItem) => void;
|
||||
onHistory: (item: WarehouseInventoryItem) => void;
|
||||
onView?: (item: WarehouseInventoryItem) => void;
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
// Optional row selection (used for bulk Mark-as-Inspected).
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
onToggleSelectAll?: () => void;
|
||||
allSelected?: boolean;
|
||||
someSelected?: boolean;
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
@@ -40,9 +50,18 @@ export function WarehouseInventoryTable({
|
||||
onAdvance,
|
||||
onMove,
|
||||
onHistory,
|
||||
onView,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
const selectable = Boolean(onToggleSelect);
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
@@ -56,6 +75,16 @@ export function WarehouseInventoryTable({
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{selectable && (
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={onToggleSelectAll}
|
||||
/>
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
@@ -76,6 +105,15 @@ export function WarehouseInventoryTable({
|
||||
const nextAction = getNextInventoryAction(item);
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
{selectable && (
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${item.bookingId ?? item.id}`}
|
||||
checked={selectedIds?.has(item.id) ?? false}
|
||||
onChange={() => onToggleSelect?.(item.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
{item.bookingId ? (
|
||||
<Tooltip label={item.bookingId} withArrow>
|
||||
@@ -108,6 +146,13 @@ export function WarehouseInventoryTable({
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{onView && (
|
||||
<Tooltip label="View details" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(item)}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{nextAction && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
@@ -119,6 +164,30 @@ export function WarehouseInventoryTable({
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{/* Batch 10 — a PICKUP_READY import item can also be stored or dispatched,
|
||||
kept separate from the customer-pickup (release/deliver) next action. */}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'store')}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'dispatch')}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
|
||||
@@ -140,6 +209,20 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onReleaseDocument && item.releaseDate && (
|
||||
<Tooltip label="View release exit paper" withArrow>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onReleaseDocument(item)}>
|
||||
<FileText size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||
<Tooltip label="Last mile delivery" withArrow>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||
<MapPin size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<History size={16} />
|
||||
|
||||
@@ -34,6 +34,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
||||
}
|
||||
|
||||
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
UNLOADED: 'indigo',
|
||||
RECEIVED: 'yellow',
|
||||
STORED: 'blue',
|
||||
RESERVED: 'grape',
|
||||
|
||||
@@ -16,6 +16,8 @@ export { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
||||
export { ActivityTimeline } from './ActivityTimeline';
|
||||
export { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
export { InventoryDetailModal } from './InventoryDetailModal';
|
||||
export { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||
export { InventoryWorkbench } from './InventoryWorkbench';
|
||||
export { BookingSelect } from './BookingSelect';
|
||||
export { WagonSelect } from './WagonSelect';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window | null) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
if (targetWindow && !targetWindow.closed) {
|
||||
targetWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return true;
|
||||
}
|
||||
|
||||
const opened = window.open(url, '_blank');
|
||||
if (opened) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return true;
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return false;
|
||||
}
|
||||
@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
|
||||
TRAIN_SCHEDULING: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/train-scheduling/available-days",
|
||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
@@ -273,11 +274,13 @@ export const URL_CONSTANTS = {
|
||||
},
|
||||
|
||||
WAREHOUSE_YARDS: {
|
||||
BASE: '/warehouse-yards',
|
||||
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||
},
|
||||
|
||||
WAREHOUSE_ZONES: {
|
||||
BASE: '/warehouse-zones',
|
||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||
},
|
||||
|
||||
@@ -315,7 +318,25 @@ export const URL_CONSTANTS = {
|
||||
// Import branch
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||
direction
|
||||
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
||||
: `/warehouse-inventory/eligible-bookings`,
|
||||
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
||||
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
||||
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
||||
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
||||
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
||||
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
||||
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
|
||||
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
||||
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
||||
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
|
||||
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
|
||||
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
|
||||
},
|
||||
|
||||
WAREHOUSE_LOADINGS: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user