Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-13 14:14:31 +00:00
219 changed files with 13585 additions and 5559 deletions

View File

@@ -17,6 +17,7 @@ import {
FindOptionsWhere,
ILike,
In,
IsNull,
LessThanOrEqual,
MoreThanOrEqual,
} from 'typeorm';
@@ -820,8 +821,9 @@ export class BookingBatchService implements OnModuleInit {
// stop order, so we fetch the day's open trains without endpoint filters.
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
// A customer-picked train narrows the scan to that ONE schedule: export
@@ -983,8 +985,9 @@ export class BookingBatchService implements OnModuleInit {
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor
@@ -1094,8 +1097,9 @@ export class BookingBatchService implements OnModuleInit {
}
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor
@@ -1219,8 +1223,9 @@ export class BookingBatchService implements OnModuleInit {
): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor.filter(
@@ -2365,11 +2370,14 @@ export class BookingBatchService implements OnModuleInit {
originStationId: originYardId,
destinationStationId: destinationYardId,
status: TrainScheduleStatusEnum.Draft,
// Dedicated shipping-line trains never join the customer day pool.
shippingLineCompanyId: IsNull(),
},
{
originStationId: originYardId,
destinationStationId: destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
shippingLineCompanyId: IsNull(),
},
],
});
@@ -3100,8 +3108,9 @@ export class BookingBatchService implements OnModuleInit {
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const schedules = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const today = eatDay(new Date());

View File

@@ -11,6 +11,8 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
{} as never, // yardFacilities
{} as never, // facilityHandling
{ emit: jest.fn() } as never, // events
{} as never, // notifications
{} as never, // inbox
);
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };

View File

@@ -24,7 +24,10 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util';
/**
* Per-booking journey along a train's corridor — for EVERY trade direction.
@@ -52,6 +55,8 @@ export class BookingJourneyService {
private readonly yardFacilities: YardFacilitiesService,
private readonly facilityHandling: FacilityHandlingService,
private readonly events: EventEmitter2,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -76,6 +81,21 @@ export class BookingJourneyService {
// Export cargo must be in the warehouse with a GRN before it can be loaded,
// however it arrived and whatever it is allocated to.
await assertExportReceivedWithGrn(this.dataSource, booking);
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
// handover moment — the carriage acceptance sheet must go out to the
// customer right here, not on a receive event that will never fire.
if (
booking.tradeDirection === 'EXPORT' &&
booking.exportHandoverMode === DIRECT_TO_TRAIN
) {
await notifyCarriageAcceptanceReady(
this.dataSource,
this.notifications,
this.inbox,
booking.id,
this.logger,
);
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {

View File

@@ -172,6 +172,17 @@ export class CreateContainerTrainScheduleDto {
@IsBoolean()
reverseWagonOrder?: boolean;
@ApiPropertyOptional({
format: 'uuid',
description:
'Dedicate this departure to one shipping line. The schedule is then hidden ' +
'from every customer-facing read (windows, day pools, home cards) and shown ' +
'only to that shipping line in its portal. Omit for a normal customer train.',
})
@IsOptional()
@IsUUID()
shippingLineCompanyId?: string;
@ApiPropertyOptional({
type: CreateScheduleWindowRuleDto,
description:

View File

@@ -181,6 +181,7 @@ describe('TrainSchedulingService', () => {
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
} as never, // bookingJourneyService
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
);
const defaultFleetWagons = [

View File

@@ -47,6 +47,7 @@ import { Container } from '../../container-management/entities/container.entity'
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
import { Train } from '../../trains/entities/train.entity';
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
@@ -129,6 +130,7 @@ import {
perEdgeConsistUsage,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput,
type WagonPlanSlot,
} from '../utils/wagon-plan.util';
@@ -176,6 +178,8 @@ import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
import { LogoSettingsService } from '../../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../../billing/documents/logo-markup.util';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
@@ -375,6 +379,7 @@ export class TrainSchedulingService {
private readonly bookingWindowGateway: BookingWindowGateway,
private readonly bookingJourneyService: BookingJourneyService,
private readonly bookingNotifier: BookingNotifierService,
private readonly logoSettings: LogoSettingsService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService,
// forwardRef: BookingBatchService injects this service back; @Optional so
@@ -1457,6 +1462,24 @@ export class TrainSchedulingService {
const scheduleWarnings: string[] = [];
// Dedicating the departure to a shipping line: the id comes from the
// request, so verify it is a real, active line before stamping it.
if (dto.shippingLineCompanyId) {
const line = await this.dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { id: dto.shippingLineCompanyId } });
if (!line) {
throw new NotFoundException(
`Shipping line ${dto.shippingLineCompanyId} not found`,
);
}
if (line.status !== 'active') {
throw new BadRequestException(
`Shipping line ${line.name} is suspended — it cannot be assigned a train`,
);
}
}
// The pulling set comes either from a built train (Train Builder) or from
// hand-picked locomotive ids (legacy path). A built train also links the
// schedule's train set back to it (`train_sets.train_id`) so its lifecycle
@@ -1721,6 +1744,7 @@ export class TrainSchedulingService {
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
shippingLineCompanyId: dto.shippingLineCompanyId ?? null,
...windowFields,
}),
);
@@ -2930,6 +2954,10 @@ export class TrainSchedulingService {
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
// Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading
// is confirmed — allocation time already enforces it, but a wagon swap or
// an edited allocation since then could have broken it unnoticed.
await this.assertWagonContainerCapacity(scheduleId);
// Export cargo must be received at the warehouse with a GRN before it can
// be confirmed loaded — an allocation is not proof the goods are in hand.
if (this.isExportSchedule(schedule)) {
@@ -2997,6 +3025,9 @@ export class TrainSchedulingService {
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? null,
equatedLengthM: wagon.wagonType?.equatedLengthM ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
@@ -3017,7 +3048,7 @@ export class TrainSchedulingService {
const loadList = await this.generateImportLoadList(scheduleId, {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
const html = this.buildImportLoadListHtml(loadList, await this.logoSettings.getLogoImageUrl());
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
@@ -3037,7 +3068,9 @@ export class TrainSchedulingService {
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
}
const html = this.buildExportLoadListHtml(schedule);
const html = this.buildExportLoadListHtml(schedule, {
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
@@ -3109,6 +3142,7 @@ export class TrainSchedulingService {
positionLabel,
wagons,
unassignedBookings,
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
@@ -3152,6 +3186,7 @@ export class TrainSchedulingService {
positionLabel?: string;
wagons?: TrainSetWagon[];
unassignedBookings?: Booking[];
logoImageUrl?: string | null;
},
): string {
const esc = (value: unknown) =>
@@ -3270,6 +3305,7 @@ export class TrainSchedulingService {
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
${logoImageCss()}
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
@@ -3284,6 +3320,7 @@ export class TrainSchedulingService {
<body>
<div class="top">
<div>
${logoMarkup(opts?.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
</div>
@@ -3397,7 +3434,76 @@ export class TrainSchedulingService {
}
}
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
/**
* Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation
* time. `validateContainerPlacements` already enforces this the moment a
* booking is allocated to a wagon, but nothing re-checks it afterwards — a
* wagon swap, an edited allocation, or a container item added out-of-band
* between allocation and loading could still leave a wagon over its 2-TEU
* capacity undetected until the train is already loaded. This closes that gap
* by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU,
* same size resolution as the marshalling document) right before loading is
* confirmed.
*/
private async assertWagonContainerCapacity(scheduleId: string): Promise<void> {
const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> =
await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
pw.wagon_number AS "wagonNumber",
SUM(
CASE COALESCE(ct.size_ft, bct.size_ft)
WHEN 40 THEN 2
WHEN 20 THEN 1
ELSE
CASE
WHEN bc.container_size ILIKE '%40%' THEN 2
WHEN bc.container_size ILIKE '%20%' THEN 1
ELSE 0
END
END
) AS "teuUsed"
FROM freight.train_set_wagons tsw
JOIN freight.wagon_booking_allocations wba
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
JOIN freight.wagon_allocation_container_items wci
ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL
LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id
LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id
LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id
LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id
WHERE tsw.train_set_id = (
SELECT train_set_id FROM freight.train_schedules WHERE id = $1
)
AND tsw.deleted_at IS NULL
GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number
HAVING SUM(
CASE COALESCE(ct.size_ft, bct.size_ft)
WHEN 40 THEN 2
WHEN 20 THEN 1
ELSE
CASE
WHEN bc.container_size ILIKE '%40%' THEN 2
WHEN bc.container_size ILIKE '%20%' THEN 1
ELSE 0
END
END
) > $2`,
[scheduleId, MAX_TEU_SLOTS_PER_WAGON],
);
if (rows.length) {
const labels = rows
.map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`)
.join(', ');
throw new BadRequestException(
`These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`,
);
}
}
private buildImportLoadListHtml(
loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>,
logoImageUrl?: string | null,
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
@@ -3430,26 +3536,37 @@ export class TrainSchedulingService {
const allocationRows = loadList.wagons
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>`;
<td>${esc(wagon.wagonNumber)}</td>
<td>${esc(wagon.wagonType)}</td>
<td class="num">${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))}</td>
<td class="num">${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}</td>
<td>${esc(loadList.origin)}</td>
<td>${esc(loadList.destination)}</td>`;
// An empty wagon still runs in the consist, so it still gets a line — see
// buildExportLoadListHtml.
if (wagon.allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
<td colspan="7">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return wagon.allocations.map(
(allocation) => {
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
const sealNumbers = (allocation.containerItems ?? [])
.map((item) => item.sealNumber)
.filter(Boolean)
.join(', ');
return `<tr>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(companyName)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td>${esc(sealNumbers || '-')}</td>
<td></td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`;
},
@@ -3477,6 +3594,7 @@ export class TrainSchedulingService {
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
.tile strong { font-size: 13px; }
${logoImageCss()}
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
@@ -3498,6 +3616,7 @@ export class TrainSchedulingService {
<div class="doc">
<div class="top">
<div>
${logoMarkup(logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Load List /<br />Marshalling Document</h1>
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
@@ -3538,15 +3657,22 @@ export class TrainSchedulingService {
<tr>
<th>Seq</th>
<th>Wagon</th>
<th>Wagon Type</th>
<th class="num">Tare</th>
<th class="num">Equated</th>
<th>Departure Station</th>
<th>Arrival Station</th>
<th>Booking</th>
<th>Company</th>
<th>Load</th>
<th>Container numbers</th>
<th>Seal No</th>
<th>Note</th>
<th class="num">Weight T</th>
</tr>
</thead>
<tbody>
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
${allocationRows || '<tr><td colspan="14">No wagons on this train set.</td></tr>'}
</tbody>
</table>
@@ -6881,6 +7007,7 @@ export class TrainSchedulingService {
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.shipping_line_company_id IS NULL
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
@@ -6937,6 +7064,7 @@ export class TrainSchedulingService {
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.shipping_line_company_id IS NULL
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
@@ -7046,6 +7174,8 @@ export class TrainSchedulingService {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
bookingWindowStatus: 'OPEN',
// Dedicated shipping-line trains never surface to customer booking.
shippingLineCompanyId: IsNull(),
},
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },