Merge pull request #107 from Tria-plc/freight/fix/train_scheduling

fix(freight): fixed some issues
This commit is contained in:
Michael Abebe
2026-06-06 11:14:36 +03:00
committed by GitHub
7 changed files with 112 additions and 78 deletions

View File

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

View File

@@ -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 = {

View File

@@ -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) =>

View File

@@ -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",

View File

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

View File

@@ -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">

View File

@@ -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 {