merge conflict

This commit is contained in:
marshal
2026-06-29 12:45:40 +03:00
202 changed files with 16895 additions and 3799 deletions

View File

@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [
'DELIVERY_ORDER',
'PORT_INVOICE',
'DJIBOUTI_T1',
'ETHIOPIA_T1',
'RAILWAY_BILL',
] as const;
export type ImportDjiboutiDocumentType = (typeof IMPORT_DJIBOUTI_DOCUMENT_TYPES)[number];
export class UploadImportDjiboutiDocumentDto {
@ApiProperty({ enum: IMPORT_DJIBOUTI_DOCUMENT_TYPES })
@IsIn(IMPORT_DJIBOUTI_DOCUMENT_TYPES)
documentType!: ImportDjiboutiDocumentType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
fileId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
fileUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
reference?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class ImportDjiboutiActionDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
export type ImportDjiboutiDocumentType =
| 'DELIVERY_ORDER'
| 'PORT_INVOICE'
| 'DJIBOUTI_T1'
| 'ETHIOPIA_T1'
| 'RAILWAY_BILL';
export interface ImportDjiboutiDocumentRecord {
fileId?: string | null;
fileUrl?: string | null;
reference?: string | null;
uploadedAt: string;
uploadedBy?: string | null;
notes?: string | null;
}
@Entity({ schema: 'freight', name: 'import_djibouti_operations' })
@Index(['trainScheduleId'], { unique: true })
export class ImportDjiboutiOperation extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@OneToOne(() => TrainSchedule)
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" })
documents!: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
@Column({ name: 'gatepass_granted_at', type: 'timestamptz', nullable: true })
gatepassGrantedAt?: Date | null;
@Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true })
readyForLoadingAt?: Date | null;
@Column({ name: 'loaded_on_train_at', type: 'timestamptz', nullable: true })
loadedOnTrainAt?: Date | null;
@Column({ name: 'departed_from_djibouti_at', type: 'timestamptz', nullable: true })
departedFromDjiboutiAt?: Date | null;
@Column({ name: 'load_list_generated_at', type: 'timestamptz', nullable: true })
loadListGeneratedAt?: Date | null;
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -8,9 +8,11 @@ import {
Patch,
Post,
Query,
Res,
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
@@ -30,6 +32,10 @@ import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.d
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
@@ -323,6 +329,101 @@ export class TrainSchedulingController {
return this.trainSchedulingService.dispatchSchedule(id);
}
@Get("schedules/:id/import-djibouti")
@TrainSchedulingView()
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })
getImportDjiboutiOperation(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getImportDjiboutiOperation(id);
}
@Post("schedules/:id/import-djibouti/documents")
@TrainSchedulingManage()
@ApiOperation({ summary: "Upload/check an import Djibouti-side document" })
uploadImportDjiboutiDocument(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UploadImportDjiboutiDocumentDto,
) {
return this.trainSchedulingService.uploadImportDjiboutiDocument(id, dto);
}
@Post("schedules/:id/import-djibouti/gatepass-granted")
@TrainSchedulingManage()
@ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" })
grantImportDjiboutiGatepass(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ImportDjiboutiActionDto,
) {
return this.trainSchedulingService.grantImportDjiboutiGatepass(id, dto);
}
@Post("schedules/:id/import-djibouti/ready-for-loading")
@TrainSchedulingManage()
@ApiOperation({ summary: "Mark import train ready for loading at Djibouti" })
markImportReadyForLoading(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ImportDjiboutiActionDto,
) {
return this.trainSchedulingService.markImportReadyForLoading(id, dto);
}
@Post("schedules/:id/import-djibouti/loaded-on-train")
@TrainSchedulingManage()
@ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" })
confirmImportLoadedOnTrain(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ImportDjiboutiActionDto,
) {
return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto);
}
@Post("schedules/:id/import-djibouti/depart")
@TrainSchedulingManage()
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
departImportFromDjibouti(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ImportDjiboutiActionDto,
) {
return this.trainSchedulingService.departImportFromDjibouti(id, dto);
}
@Post("schedules/:id/import-djibouti/load-list")
@TrainSchedulingManage()
@ApiOperation({ summary: "Generate import load list / marshalling document summary" })
generateImportLoadList(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ImportDjiboutiActionDto,
) {
return this.trainSchedulingService.generateImportLoadList(id, dto);
}
@Get("schedules/:id/import-djibouti/load-list/document")
@TrainSchedulingView()
@ApiOperation({ summary: "Download printable import load list / marshalling PDF" })
async importLoadListDocument(
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.trainSchedulingService.importLoadListDocument(id);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
return res.send(buffer);
}
@Get("schedules/:id/export/load-list/document")
@TrainSchedulingView()
@ApiOperation({ summary: "Download printable export marshalling / load list PDF" })
async exportLoadListDocument(
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.trainSchedulingService.exportLoadListDocument(id);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
return res.send(buffer);
}
// ---- batch / booking-window staff actions ----
@Post("schedules/:id/run-batch")

View File

@@ -15,7 +15,9 @@ 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 { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { TrainSchedulingController } from './train-scheduling.controller';
@@ -37,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
Container,
TrainSchedulingGlobalRules,
TrainCheckpointEvent,
ImportDjiboutiOperation,
]),
forwardRef(() => BookingsModule),
NotificationsModule,
@@ -44,6 +47,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
forwardRef(() => WarehousesModule),
RuleEngineModule,
],
controllers: [TrainSchedulingController],

View File

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

View File

@@ -49,6 +49,15 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import {
ImportDjiboutiOperation,
type ImportDjiboutiDocumentType,
} from './entities/import-djibouti-operation.entity';
import {
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import {
buildCappedWagonPlan,
@@ -95,6 +104,9 @@ 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 { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
@@ -167,6 +179,8 @@ export class TrainSchedulingService {
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly configService?: ConfigService,
) {}
@@ -602,6 +616,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) {
@@ -696,6 +778,7 @@ export class TrainSchedulingService {
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
await this.assertImportDjiboutiMayDepart(schedule);
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -735,9 +818,555 @@ export class TrainSchedulingService {
.execute();
});
if (this.isImportDjiboutiSchedule(schedule)) {
const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
});
console.log(
`[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
);
}
return this.getTrainScheduleById(scheduleId);
}
async getImportDjiboutiOperation(scheduleId: string) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
return this.mapImportDjiboutiOperation(schedule, operation);
}
async uploadImportDjiboutiDocument(
scheduleId: string,
dto: UploadImportDjiboutiDocumentDto,
) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const documents = {
...(operation.documents ?? {}),
[dto.documentType]: {
fileId: dto.fileId ?? null,
fileUrl: dto.fileUrl ?? null,
reference: dto.reference ?? null,
uploadedAt: new Date().toISOString(),
uploadedBy: dto.performedBy ?? null,
notes: dto.notes ?? null,
},
};
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
documents,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const missing = this.missingImportDjiboutiDocuments(operation);
if (missing.length) {
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`);
}
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
console.log(
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`,
);
return this.getImportDjiboutiOperation(schedule.id);
}
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
if (!operation.loadedOnTrainAt) {
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
}
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
await this.dispatchSchedule(schedule.id);
} else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
}
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const generatedAt = operation.loadListGeneratedAt ?? new Date();
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
loadListGeneratedAt: generatedAt,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return {
generatedAt: generatedAt.toISOString(),
trainScheduleId: schedule.id,
trainNumber: schedule.trainNumber ?? null,
route: schedule.route?.name ?? null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
totalBookings: schedule.scheduleBookings?.length ?? 0,
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
})),
})),
operation: await this.getImportDjiboutiOperation(schedule.id),
};
}
async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const loadList = await this.generateImportLoadList(scheduleId, {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.isExportSchedule(schedule)) {
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
}
const html = this.buildExportLoadListHtml(schedule);
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
private buildExportLoadListHtml(schedule: TrainSchedule): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
const rows = (schedule.trainSet?.wagons ?? [])
.flatMap((wagon) =>
(wagon.allocations ?? []).map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const company = booking?.company as Record<string, unknown> | null | undefined;
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
const containerItems = allocation.containerItems ?? [];
const firstContainer = containerItems[0];
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr>
<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
<td>${esc(booking?.companyId)}</td>
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td>
</tr>`;
}),
)
.join('');
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
(sum, wagon) =>
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Export Marshalling Document</title>
<style>
@page { size: A4 landscape; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.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; }
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; }
.num { text-align: right; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Export Marshalling Document / Load List</h1>
</div>
<div class="meta">
Train / Schedule
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
</div>
<table>
<thead>
<tr>
<th>Seq</th>
<th>Wagon No</th>
<th>Wagon Type</th>
<th class="num">Equated Length</th>
<th class="num">Tare Weight</th>
<th class="num">Load Capacity</th>
<th>Customer Name</th>
<th>Customer ID</th>
<th>Cargo Type</th>
<th>Container No</th>
<th>Chassis No</th>
<th>Seal No</th>
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
</tbody>
</table>
<div class="notice">
Loading and dispatch staff must verify wagon identity, seal number, container number,
cargo type, and customer booking against the physical consist before departure.
</div>
<div class="signatures">
<div class="line">Prepared person / date</div>
<div class="line">Check person / date</div>
<div class="line">Operations authorization / date</div>
</div>
</body>
</html>`;
}
private isExportSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return direction === 'EXPORT';
}
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
const status = loadList.operation.status;
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
const totalWeight = loadList.wagons.reduce(
(sum, wagon) =>
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
const allocationRows = loadList.wagons
.flatMap((wagon) =>
wagon.allocations.map(
(allocation) => `<tr>
<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`,
),
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Import Load List / Marshalling Document</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.doc { min-height: 100vh; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
.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; }
.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; }
.pending { background: #f8fafc; color: #64748b; }
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
.num { text-align: right; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
.footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<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>
</div>
<div class="meta">
Train / Schedule
<strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
Generated: ${esc(date(loadList.generatedAt))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div>
<div class="status">
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
</div>
<h2>Wagon Marshalling Allocation</h2>
<table>
<thead>
<tr>
<th>Seq</th>
<th>Wagon</th>
<th>Booking</th>
<th>Load</th>
<th>Container numbers</th>
<th class="num">Weight T</th>
</tr>
</thead>
<tbody>
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
</tbody>
</table>
<div class="notice">
Gate and loading staff must verify this document against the granted gatepass,
railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure.
</div>
<div class="signatures">
<div class="line">Prepared by Djibouti operations</div>
<div class="line">Train loading supervisor</div>
<div class="line">EDR operations authorization</div>
</div>
<div class="footer">
System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
</div>
</div>
</body>
</html>`;
}
private safeDocumentName(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
if (!this.isImportDjiboutiSchedule(schedule)) return;
const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({
where: { trainScheduleId: schedule.id },
});
this.assertImportDjiboutiGatepassGranted(operation);
if (!operation?.loadedOnTrainAt) {
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
}
}
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.isImportDjiboutiSchedule(schedule)) {
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti');
}
return schedule;
}
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return (
direction === 'IMPORT' &&
this.isDjiboutiPortDestination(
`${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`,
)
);
}
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
if (existing) return existing;
return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} }));
}
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
const documents = operation?.documents ?? {};
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]);
}
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
if (!operation?.gatepassGrantedAt) {
throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted');
}
}
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
return {
trainScheduleId: schedule.id,
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
status: {
documentsComplete: missingDocuments.length === 0,
missingDocuments,
gatepassGranted: Boolean(operation.gatepassGrantedAt),
readyForLoading: Boolean(operation.readyForLoadingAt),
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt),
loadListGenerated: Boolean(operation.loadListGeneratedAt),
},
documents: operation.documents ?? {},
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
readyForLoadingAt: operation.readyForLoadingAt ?? null,
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
loadListGeneratedAt: operation.loadListGeneratedAt ?? null,
performedBy: operation.performedBy ?? null,
notes: operation.notes ?? null,
};
}
/**
* Assign a fixed train number on dispatch. The number is drawn from the pool
* for the train's dominant cargo type (container vs bulk) and trade direction
@@ -1063,7 +1692,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 getContainerTrainSchedules() {