Autounload on train arrival

This commit is contained in:
hagiye
2026-06-26 12:17:31 +03:00
parent 73de29f7c5
commit 75d4c9b7c1
11 changed files with 127 additions and 23 deletions

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

@@ -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,
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,6 +1,6 @@
import axios from "axios";
import { API_BASE_URL } from "@/constants/apiConfig";
import { API_BASE_URL } from "@/pages/fleet/config/vehicles";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,

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 "@/pages/fleet/config/vehicles";
interface Cargo {
id: string;

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

@@ -92,3 +92,6 @@ export const vehiclesConfig: FleetResourceConfig = {
};
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';

View File

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