|
|
|
|
@@ -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, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
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, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
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() {
|
|
|
|
|
|