mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Merge pull request #107 from Tria-plc/freight/fix/train_scheduling
fix(freight): fixed some issues
This commit is contained in:
@@ -1,7 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
import { BOOKING_STATUSES } from '../../bookings/entities/booking.entity';
|
|
||||||
|
|
||||||
export class GetEligibleContainerBookingsDto {
|
export class GetEligibleContainerBookingsDto {
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@@ -18,9 +16,4 @@ export class GetEligibleContainerBookingsDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduleDate?: string;
|
scheduleDate?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(BOOKING_STATUSES)
|
|
||||||
status?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const makeBooking = (
|
|||||||
scheduledDate: new Date(scheduledDate),
|
scheduledDate: new Date(scheduledDate),
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
status: 'APPROVED',
|
status: 'PAID',
|
||||||
customer: { companyName: 'Demo Customer' },
|
customer: { companyName: 'Demo Customer' },
|
||||||
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
|
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
|
||||||
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
|
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
|
||||||
@@ -163,6 +163,44 @@ describe('TrainSchedulingService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects bookings that are not in schedulable status', async () => {
|
||||||
|
const bookings = [
|
||||||
|
{
|
||||||
|
...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'),
|
||||||
|
status: 'APPROVED',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
|
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||||
|
if (entity?.name === 'Booking') {
|
||||||
|
return { find: jest.fn().mockResolvedValue(bookings) };
|
||||||
|
}
|
||||||
|
if (entity?.name === 'TrainScheduleBooking') {
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
if (entity?.name === 'Locomotive') {
|
||||||
|
return {
|
||||||
|
count: jest.fn().mockResolvedValue(1),
|
||||||
|
find: jest.fn().mockResolvedValue([locomotive]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected repository ${entity?.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.previewContainerTrainSchedule({
|
||||||
|
bookingIds: ['b7'],
|
||||||
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-destination',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.violations).toContain(
|
||||||
|
'Only PAID bookings can be scheduled; received: APPROVED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates a schedule transactionally when validation passes', async () => {
|
it('creates a schedule transactionally when validation passes', async () => {
|
||||||
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
|
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
|
||||||
const validation = {
|
const validation = {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-
|
|||||||
const DEFAULT_WAGON_TYPE_CODE = "NW5";
|
const DEFAULT_WAGON_TYPE_CODE = "NW5";
|
||||||
const MAX_TRAIN_WEIGHT_TONS = 3500;
|
const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||||
const MAX_TRAIN_LENGTH_METERS = 760;
|
const MAX_TRAIN_LENGTH_METERS = 760;
|
||||||
|
const SCHEDULABLE_BOOKING_STATUSES = ["PAID"] as const;
|
||||||
|
|
||||||
type EligibleBookingItem = {
|
type EligibleBookingItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -83,7 +84,7 @@ export class TrainSchedulingService {
|
|||||||
const bookingRepository = this.dataSource.getRepository(Booking);
|
const bookingRepository = this.dataSource.getRepository(Booking);
|
||||||
const queryBuilder = bookingRepository
|
const queryBuilder = bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.leftJoinAndSelect("booking.customer", "customer")
|
.leftJoinAndSelect("booking.company", "company")
|
||||||
.leftJoinAndSelect("booking.originYard", "originYard")
|
.leftJoinAndSelect("booking.originYard", "originYard")
|
||||||
.leftJoinAndSelect("booking.destinationYard", "destinationYard")
|
.leftJoinAndSelect("booking.destinationYard", "destinationYard")
|
||||||
.leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
|
.leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
|
||||||
@@ -96,6 +97,10 @@ export class TrainSchedulingService {
|
|||||||
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
|
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
|
||||||
.andWhere("scheduleBooking.id IS NULL");
|
.andWhere("scheduleBooking.id IS NULL");
|
||||||
|
|
||||||
|
queryBuilder.andWhere("booking.status IN (:...schedulableStatuses)", {
|
||||||
|
schedulableStatuses: SCHEDULABLE_BOOKING_STATUSES,
|
||||||
|
});
|
||||||
|
|
||||||
if (query.originStationId) {
|
if (query.originStationId) {
|
||||||
queryBuilder.andWhere("booking.originYardId = :originStationId", {
|
queryBuilder.andWhere("booking.originYardId = :originStationId", {
|
||||||
originStationId: query.originStationId,
|
originStationId: query.originStationId,
|
||||||
@@ -118,12 +123,6 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.status) {
|
|
||||||
queryBuilder.andWhere("booking.status = :status", {
|
|
||||||
status: query.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookings = await queryBuilder
|
const bookings = await queryBuilder
|
||||||
.orderBy("booking.scheduled_date", "ASC")
|
.orderBy("booking.scheduled_date", "ASC")
|
||||||
.addOrderBy("booking.created_at", "ASC")
|
.addOrderBy("booking.created_at", "ASC")
|
||||||
@@ -357,6 +356,16 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invalidStatusBookings = bookings.filter(
|
||||||
|
(booking) => !SCHEDULABLE_BOOKING_STATUSES.includes(booking.status as "PAID"),
|
||||||
|
);
|
||||||
|
if (invalidStatusBookings.length > 0) {
|
||||||
|
const invalidStatuses = [...new Set(invalidStatusBookings.map((booking) => booking.status))];
|
||||||
|
violations.push(
|
||||||
|
`Only ${SCHEDULABLE_BOOKING_STATUSES.join(", ")} bookings can be scheduled; received: ${invalidStatuses.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
|
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
|
||||||
const routeMismatch = bookings.some(
|
const routeMismatch = bookings.some(
|
||||||
(booking) =>
|
(booking) =>
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const DEMO_BOOKINGS = [
|
|||||||
originCode: "DJIBOUTI",
|
originCode: "DJIBOUTI",
|
||||||
destinationCode: "ADDIS_ABABA",
|
destinationCode: "ADDIS_ABABA",
|
||||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
reference: "BKG-CONT-002",
|
reference: "BKG-CONT-002",
|
||||||
@@ -60,6 +62,8 @@ const DEMO_BOOKINGS = [
|
|||||||
originCode: "DJIBOUTI",
|
originCode: "DJIBOUTI",
|
||||||
destinationCode: "ADDIS_ABABA",
|
destinationCode: "ADDIS_ABABA",
|
||||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
reference: "BKG-CONT-003",
|
reference: "BKG-CONT-003",
|
||||||
@@ -69,6 +73,8 @@ const DEMO_BOOKINGS = [
|
|||||||
originCode: "DJIBOUTI",
|
originCode: "DJIBOUTI",
|
||||||
destinationCode: "ADDIS_ABABA",
|
destinationCode: "ADDIS_ABABA",
|
||||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
reference: "BKG-CONT-007",
|
reference: "BKG-CONT-007",
|
||||||
@@ -78,6 +84,30 @@ const DEMO_BOOKINGS = [
|
|||||||
originCode: "DJIBOUTI",
|
originCode: "DJIBOUTI",
|
||||||
destinationCode: "ADDIS_ABABA",
|
destinationCode: "ADDIS_ABABA",
|
||||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-CONT-008",
|
||||||
|
containerCode: "40FT",
|
||||||
|
quantity: 4,
|
||||||
|
totalWeightTons: 120,
|
||||||
|
originCode: "DJIBOUTI",
|
||||||
|
destinationCode: "ADDIS_ABABA",
|
||||||
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "BKG-CONT-009",
|
||||||
|
containerCode: "20FT",
|
||||||
|
quantity: 5,
|
||||||
|
totalWeightTons: 110,
|
||||||
|
originCode: "DJIBOUTI",
|
||||||
|
destinationCode: "ADDIS_ABABA",
|
||||||
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
reference: "BKG-CONT-004",
|
reference: "BKG-CONT-004",
|
||||||
@@ -87,6 +117,8 @@ const DEMO_BOOKINGS = [
|
|||||||
originCode: "ADDIS_ABABA",
|
originCode: "ADDIS_ABABA",
|
||||||
destinationCode: "DIRE_DAWA",
|
destinationCode: "DIRE_DAWA",
|
||||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
reference: "BKG-CONT-005",
|
reference: "BKG-CONT-005",
|
||||||
@@ -96,6 +128,8 @@ const DEMO_BOOKINGS = [
|
|||||||
originCode: "DJIBOUTI",
|
originCode: "DJIBOUTI",
|
||||||
destinationCode: "ADDIS_ABABA",
|
destinationCode: "ADDIS_ABABA",
|
||||||
scheduledDate: "2026-06-21T08:00:00.000Z",
|
scheduledDate: "2026-06-21T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
reference: "BKG-CONT-006",
|
reference: "BKG-CONT-006",
|
||||||
@@ -105,6 +139,8 @@ const DEMO_BOOKINGS = [
|
|||||||
originCode: "DJIBOUTI",
|
originCode: "DJIBOUTI",
|
||||||
destinationCode: "ADDIS_ABABA",
|
destinationCode: "ADDIS_ABABA",
|
||||||
scheduledDate: "2026-06-20T08:00:00.000Z",
|
scheduledDate: "2026-06-20T08:00:00.000Z",
|
||||||
|
status: "PAID",
|
||||||
|
paymentStatus: "PAID",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -249,10 +285,10 @@ export class DemoBookingsSeeder {
|
|||||||
{
|
{
|
||||||
reference: demoBooking.reference,
|
reference: demoBooking.reference,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
status: "APPROVED",
|
status: demoBooking.status,
|
||||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
paymentStatus: "PENDING",
|
paymentStatus: demoBooking.paymentStatus,
|
||||||
contractType: "NEW",
|
contractType: "NEW",
|
||||||
serviceTypeId: serviceType.id,
|
serviceTypeId: serviceType.id,
|
||||||
equipmentReturn: "WITHOUT_RETURN",
|
equipmentReturn: "WITHOUT_RETURN",
|
||||||
|
|||||||
@@ -34,15 +34,15 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
|||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import TrainsPage from "./pages/trains/TrainsPage";
|
import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
import {
|
import {
|
||||||
CargoesCrudPage,
|
CargoesCrudPage,
|
||||||
ContainersCrudPage,
|
ContainersCrudPage,
|
||||||
TrainMasterDataPage,
|
TrainMasterDataPage,
|
||||||
WagonsCrudPage,
|
WagonsCrudPage,
|
||||||
} from "./pages/fleet/FleetCrudPages";
|
} from "./pages/fleet/FleetCrudPages";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -173,26 +173,7 @@ const DashboardShell = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const demoItems: SidebarItem[] = [
|
const demoItems: SidebarItem[] = [];
|
||||||
...(hasPermission(user, "can:demo:user1")
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "User1",
|
|
||||||
href: "/dashboard/user1",
|
|
||||||
icon: <Settings />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(hasPermission(user, "can:demo:user2")
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: "User2",
|
|
||||||
href: "/dashboard/user2",
|
|
||||||
icon: <Settings />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
const sidebarSections = buildSidebarSections(demoItems);
|
const sidebarSections = buildSidebarSections(demoItems);
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
@@ -241,13 +222,13 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||||
<Route path="containers" element={<ContainersCrudPage />} />
|
<Route path="containers" element={<ContainersCrudPage />} />
|
||||||
<Route path="cargoes" element={<CargoesCrudPage />} />
|
<Route path="cargoes" element={<CargoesCrudPage />} />
|
||||||
|
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
|
|||||||
@@ -358,28 +358,6 @@ const TrainsPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Booking status</label>
|
|
||||||
<Select
|
|
||||||
value={filters.status ?? '__all__'}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilters((current) => ({
|
|
||||||
...current,
|
|
||||||
status: value === '__all__' ? undefined : value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="All statuses" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__all__">All eligible statuses</SelectItem>
|
|
||||||
<SelectItem value="PAID">Paid</SelectItem>
|
|
||||||
<SelectItem value="FULLY_EXECUTED">Fully executed</SelectItem>
|
|
||||||
<SelectItem value="APPROVED">Approved</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -388,7 +366,7 @@ const TrainsPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Eligible container bookings</h2>
|
<h2 className="text-lg font-semibold">Eligible container bookings</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Only container bookings not already assigned to a schedule appear here.
|
Only paid container bookings not already assigned to a schedule appear here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ export interface TrainScheduleFilters {
|
|||||||
originStationId?: string;
|
originStationId?: string;
|
||||||
destinationStationId?: string;
|
destinationStationId?: string;
|
||||||
scheduleDate?: string;
|
scheduleDate?: string;
|
||||||
status?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrainSchedulePreviewPayload {
|
export interface TrainSchedulePreviewPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user