Merge branch 'dev' into alpha

This commit is contained in:
Abubeker Yasin
2026-06-26 14:32:30 +03:00
22 changed files with 283 additions and 87 deletions

View File

@@ -17,6 +17,7 @@
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
@@ -38,7 +39,8 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Fix for fresh deployments: AddWarehouseInspection1750000000003 runs before
* the warehouse_inventory table exists, so it cannot add inspection_status.
*/
export class AddWarehouseInventoryInspectionStatusFix1791000000005 implements MigrationInterface {
private readonly table = 'freight.warehouse_inventory';
public async up(queryRunner: QueryRunner): Promise<void> {
if ((await queryRunner.hasTable(this.table)) && !(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
await queryRunner.addColumn(
this.table,
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
if ((await queryRunner.hasTable(this.table)) && (await queryRunner.hasColumn(this.table, 'inspection_status'))) {
await queryRunner.dropColumn(this.table, 'inspection_status');
}
}
}

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
@@ -13,7 +13,7 @@ import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile]),
BookingsModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,

View File

@@ -15,6 +15,7 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { Wagon } from '../wagons/entities/wagon.entity';
import { WarehousesModule } from '../warehouses/warehouses.module';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
@@ -44,6 +45,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
forwardRef(() => WarehousesModule),
RuleEngineModule,
],
controllers: [TrainSchedulingController],

View File

@@ -147,6 +147,10 @@ describe('TrainSchedulingService', () => {
wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never,
{} as never, // trainCompositionRemovalLogRepository
{
autoUnloadArrivedBookings: jest.fn(),
autoUnloadExportAtDjibouti: jest.fn(),
} as never,
);
const defaultFleetWagons = [

View File

@@ -95,6 +95,8 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
@@ -167,6 +169,7 @@ export class TrainSchedulingService {
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly configService?: ConfigService,
) {}
@@ -602,6 +605,74 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
private async runWarehouseArrivalAutomation(scheduleId: string) {
const [schedule]: Array<{
originCountry: string | null;
destinationCountry: string | null;
destinationCode: string | null;
destinationName: string | null;
}> = await this.dataSource.query(
`SELECT oy.country AS "originCountry",
dy.country AS "destinationCountry",
dy.code AS "destinationCode",
dy.name AS "destinationName"
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.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
const direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
try {
if (direction === 'IMPORT') {
return {
direction,
action: 'IMPORT_AUTO_UNLOAD',
status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
};
}
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
return {
direction,
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
};
}
return { direction, status: 'SKIPPED', reason: 'No warehouse arrival automation for this route' };
} catch (error) {
return {
direction,
status: 'FAILED',
reason: error instanceof Error ? error.message : String(error),
};
}
}
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
const normalized = (value ?? '').toUpperCase();
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
normalized.includes(token),
);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -653,7 +724,9 @@ export class TrainSchedulingService {
}
});
return this.getTrainScheduleById(scheduleId);
const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation });
}
async finalizeSchedule(scheduleId: string) {

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -70,7 +70,7 @@ import { WarehousesService } from './warehouses.service';
]),
FilesModule,
InterchangeDocumentsModule,
LastMileModule,
forwardRef(() => LastMileModule),
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -0,0 +1,40 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
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 { IndodeFacilitySeeder } from '../seed/indode-facility.seeder';
import { PricingDataSeeder } from '../seed/pricing-data.seeder';
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
await app.get(PricingDataSeeder).run();
await app.get(IndodeFacilitySeeder).run();
await app.get(Batch14TestDataSeeder).run();
await app.get(Batch5TestDataSeeder).run();
await app.get(Batch7TestDataSeeder).run();
await app.get(Batch8TestDataSeeder).run();
await app.get(WarehouseDemoSeeder).run();
console.log('Warehouse demo data seeded.');
} finally {
await app.close();
}
}
main().catch((err) => {
console.error('Warehouse demo seed failed:', err);
process.exit(1);
});

View File

@@ -12,6 +12,7 @@ import {
Paperclip,
Send,
Settings,
ShieldCheck,
SlidersHorizontal,
Train,
Truck,
@@ -27,6 +28,8 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
@@ -111,6 +114,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Operations",
items: [
{
label: "Document Clearance",
href: "/dashboard/clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.bookings.reviewDocuments,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
@@ -388,6 +397,22 @@ const App = () => {
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route
path="clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<DocumentClearanceListPage />
</RequirePermission>
}
/>
<Route
path="clearance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<DocumentClearanceDetailPage />
</RequirePermission>
}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />

View File

@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { API_BASE_URL } from '@/constants/apiConfig';
import { API_BASE_URL } from "@/constants/apiConfig";
interface Cargo {
id: string;

View File

@@ -181,7 +181,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</ActionIcon>
</Group>
),
},
},
];
return (

View File

@@ -1,3 +1,3 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';
//export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -9,6 +10,16 @@ import {
} from "@/services/bookings.service";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
const parseApiError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
};
export function useBookingList(filter?: BookingListFilter, enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.BOOKINGS.list(filter),
@@ -85,7 +96,7 @@ export function useBookingMutations(bookingId: string) {
requiredRole,
}),
onSuccess: (data) => onSuccess(data, "Approval step completed"),
onError: () => toast.error("Failed to approve step"),
onError: (error) => toast.error(parseApiError(error, "Failed to approve step")),
});
const rejectStep = useMutation({
@@ -102,7 +113,7 @@ export function useBookingMutations(bookingId: string) {
reason,
}),
onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
onError: () => toast.error("Failed to reject step"),
onError: (error) => toast.error(parseApiError(error, "Failed to reject step")),
});
const generateContract = useMutation({

View File

@@ -42,19 +42,10 @@ import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { api } from "@/auth/http";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { URL_CONSTANTS } from "@/constants/URLS";
import { api as appApi } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { unwrap } from "@/utils/endpoint";
interface CompanyOption {
id: string;
name?: string | null;
tin?: string | null;
email?: string | null;
}
import { customersService } from "@/services/customers.service";
type FreightType = "CONTAINER" | "BULK";
@@ -219,15 +210,12 @@ export default function NewBookingPage() {
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
});
const { data: companies, isLoading: companiesLoading } = useQuery({
const { data: companiesPage, isLoading: companiesLoading } = useQuery({
queryKey: ["companies", "list"],
queryFn: async () => {
const res = await api.get(URL_CONSTANTS.COMPANIES.BASE);
return unwrap(res.data) as CompanyOption[];
},
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
});
const companyOptions = (companies ?? []).map((c) => ({
const companyOptions = (companiesPage?.items ?? []).map((c) => ({
value: c.id,
label: c.name || c.email || c.tin || c.id,
}));

View File

@@ -1,4 +1,5 @@
import type { FleetResourceConfig } from "./resources";
import { API_BASE_URL } from "@/constants/apiConfig";
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
@@ -92,3 +93,4 @@ export const vehiclesConfig: FleetResourceConfig = {
};
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
export { API_BASE_URL };

View File

@@ -1,3 +1,2 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -38,7 +38,7 @@
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz",
"@types/bcrypt": "^6.0.0",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",