Import operation gate pass

This commit is contained in:
hagiye
2026-06-27 21:04:27 +03:00
parent 3441ee4a0a
commit fee4365b70
37 changed files with 3084 additions and 116 deletions

View File

@@ -0,0 +1,188 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity';
import {
EMPTY_CONTAINER_RETURN_STATUSES,
type EmptyContainerReturnStatus,
} from '../entities/empty-container-return.entity';
import {
IMPORT_CUSTOMS_RISK_LEVELS,
type ImportCustomsDocumentType,
type ImportCustomsRiskLevel,
} from '../entities/import-customs-finalization.entity';
export const IMPORT_CUSTOMS_DOCUMENT_TYPES = [
'IM4',
'IM5',
'T1_CLOSURE_PROOF',
'TRANSIT_PERMIT_SCREENSHOT',
'CUSTOMER_PAYMENT_SLIP',
'IMPORT_RELEASE_PERMIT',
] as const;
export class CreateDjiboutiIncidentDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerNumber?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
facility?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
station?: string;
@ApiProperty({ enum: DJIBOUTI_INCIDENT_TYPES })
@IsIn(DJIBOUTI_INCIDENT_TYPES)
incidentType!: DjiboutiIncidentType;
@ApiProperty()
@IsString()
description!: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
photos?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
reportedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
reportedAt?: string;
}
export class UploadImportCustomsDocumentDto {
@ApiProperty({ enum: IMPORT_CUSTOMS_DOCUMENT_TYPES })
@IsIn(IMPORT_CUSTOMS_DOCUMENT_TYPES)
documentType!: ImportCustomsDocumentType;
@ApiProperty()
@IsString()
fileId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class RecordDeclarationDto {
@ApiProperty()
@IsString()
declarationSerialNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class AssignCustomsRiskDto {
@ApiProperty({ enum: IMPORT_CUSTOMS_RISK_LEVELS })
@IsIn(IMPORT_CUSTOMS_RISK_LEVELS)
risk!: ImportCustomsRiskLevel;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class ImportOperationActionDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class CreateEmptyContainerReturnDto {
@ApiProperty()
@IsString()
containerNumber!: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
bookingId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
returnDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
facility?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
yard?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
zone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
condition?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
handoverNote?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {
@ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES })
@IsIn(EMPTY_CONTAINER_RETURN_STATUSES)
status!: EmptyContainerReturnStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
wagonAllocationReference?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
handoverNote?: string;
}

View File

@@ -0,0 +1,50 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const DJIBOUTI_INCIDENT_TYPES = [
'SEAL_BROKEN',
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
'QUANTITY_MISMATCH',
'WEIGHT_MISMATCH',
'OTHER',
] as const;
export type DjiboutiIncidentType = (typeof DJIBOUTI_INCIDENT_TYPES)[number];
@Entity({ schema: 'freight', name: 'djibouti_import_incidents' })
@Index(['bookingId'])
@Index(['containerNumber'])
@Index(['incidentType'])
export class DjiboutiIncident extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'container_number', type: 'varchar', length: 80, nullable: true })
containerNumber?: string | null;
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
cargoId?: string | null;
@Column({ name: 'facility', type: 'varchar', length: 120, nullable: true })
facility?: string | null;
@Column({ name: 'station', type: 'varchar', length: 120, nullable: true })
station?: string | null;
@Column({ name: 'incident_type', type: 'varchar', length: 40 })
incidentType!: DjiboutiIncidentType;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'photos', type: 'jsonb', default: () => "'[]'::jsonb" })
photos!: string[];
@Column({ name: 'reported_by', type: 'varchar', length: 120, nullable: true })
reportedBy?: string | null;
@Column({ name: 'reported_at', type: 'timestamptz' })
reportedAt!: Date;
}

View File

@@ -0,0 +1,56 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const EMPTY_CONTAINER_RETURN_STATUSES = [
'RETURNED',
'ASSIGNED_STORAGE',
'DOCUMENTATION_CLEARED',
'WAGON_ALLOCATED',
'TRANSPORTED_TO_DJIBOUTI',
'HANDOVER_ISSUED',
'COMPLETED',
] as const;
export type EmptyContainerReturnStatus = (typeof EMPTY_CONTAINER_RETURN_STATUSES)[number];
@Entity({ schema: 'freight', name: 'empty_container_returns' })
@Index(['containerNumber'])
@Index(['bookingId'])
@Index(['status'])
export class EmptyContainerReturn extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 80 })
containerNumber!: string;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId?: string | null;
@Column({ name: 'return_date', type: 'timestamptz' })
returnDate!: Date;
@Column({ name: 'facility', type: 'varchar', length: 120, nullable: true })
facility?: string | null;
@Column({ name: 'yard', type: 'varchar', length: 120, nullable: true })
yard?: string | null;
@Column({ name: 'zone', type: 'varchar', length: 120, nullable: true })
zone?: string | null;
@Column({ name: 'condition', type: 'text', nullable: true })
condition?: string | null;
@Column({ name: 'handover_note', type: 'text', nullable: true })
handoverNote?: string | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'RETURNED' })
status!: EmptyContainerReturnStatus;
@Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true })
wagonAllocationReference?: string | null;
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
}

View File

@@ -0,0 +1,48 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const IMPORT_CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'BLUE', 'RED'] as const;
export type ImportCustomsRiskLevel = (typeof IMPORT_CUSTOMS_RISK_LEVELS)[number];
export type ImportCustomsDocumentType =
| 'IM4'
| 'IM5'
| 'T1_CLOSURE_PROOF'
| 'TRANSIT_PERMIT_SCREENSHOT'
| 'CUSTOMER_PAYMENT_SLIP'
| 'IMPORT_RELEASE_PERMIT';
@Entity({ schema: 'freight', name: 'import_customs_finalizations' })
@Index(['bookingId'], { unique: true })
@Index(['customsRisk'])
export class ImportCustomsFinalization extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" })
documents!: Partial<Record<ImportCustomsDocumentType, string>>;
@Column({ name: 'declaration_serial_number', type: 'varchar', length: 120, nullable: true })
declarationSerialNumber?: string | null;
@Column({ name: 'duties_taxes_notified_at', type: 'timestamptz', nullable: true })
dutiesTaxesNotifiedAt?: Date | null;
@Column({ name: 'duties_taxes_paid_at', type: 'timestamptz', nullable: true })
dutiesTaxesPaidAt?: Date | null;
@Column({ name: 'customs_risk', type: 'varchar', length: 12, nullable: true })
customsRisk?: ImportCustomsRiskLevel | null;
@Column({ name: 'import_release_permitted_at', type: 'timestamptz', nullable: true })
importReleasePermittedAt?: Date | null;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt?: 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

@@ -0,0 +1,110 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
RecordDeclarationDto,
UpdateEmptyContainerReturnStatusDto,
UploadImportCustomsDocumentDto,
} from './dto/import-operations.dto';
import { ImportOperationsService } from './import-operations.service';
@ApiTags('import-operations')
@ApiBearerAuth()
@Controller('import-operations')
export class ImportOperationsController {
constructor(private readonly service: ImportOperationsService) {}
@Get('djibouti-incidents')
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
listIncidents(@Query('bookingId') bookingId?: string) {
return this.service.listIncidents(bookingId);
}
@Post('djibouti-incidents')
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
return this.service.createIncident(dto);
}
@Get('customs/:bookingId')
@ApiOperation({ summary: 'Batch 12: import customs finalization state' })
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.service.getCustoms(bookingId);
}
@Post('customs/:bookingId/documents')
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
uploadCustomsDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: UploadImportCustomsDocumentDto,
) {
return this.service.uploadCustomsDocument(bookingId, dto);
}
@Post('customs/:bookingId/declaration')
@ApiOperation({ summary: 'Batch 12: record declaration serial number' })
recordDeclaration(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: RecordDeclarationDto,
) {
return this.service.recordDeclaration(bookingId, dto);
}
@Post('customs/:bookingId/notify-duties-taxes')
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
notifyDutiesTaxes(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.notifyDutiesTaxes(bookingId, dto);
}
@Post('customs/:bookingId/duties-taxes-paid')
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
markDutiesTaxesPaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.markDutiesTaxesPaid(bookingId, dto);
}
@Post('customs/:bookingId/risk')
@ApiOperation({ summary: 'Batch 12: assign customs risk' })
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
return this.service.assignRisk(bookingId, dto);
}
@Post('customs/:bookingId/release-permitted')
@ApiOperation({ summary: 'Batch 12: mark import release permitted' })
markReleasePermitted(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.markReleasePermitted(bookingId, dto);
}
@Get('empty-container-returns')
@ApiOperation({ summary: 'Batch 16: list empty container returns' })
listEmptyReturns() {
return this.service.listEmptyReturns();
}
@Post('empty-container-returns')
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
return this.service.createEmptyReturn(dto);
}
@Post('empty-container-returns/:id/status')
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
updateEmptyReturnStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEmptyContainerReturnStatusDto,
) {
return this.service.updateEmptyReturnStatus(id, dto);
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
import { ImportOperationsController } from './import-operations.controller';
import { ImportOperationsService } from './import-operations.service';
@Module({
imports: [
TypeOrmModule.forFeature([
DjiboutiIncident,
ImportCustomsFinalization,
EmptyContainerReturn,
]),
],
controllers: [ImportOperationsController],
providers: [ImportOperationsService],
exports: [ImportOperationsService],
})
export class ImportOperationsModule {}

View File

@@ -0,0 +1,211 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
RecordDeclarationDto,
AssignCustomsRiskDto,
UpdateEmptyContainerReturnStatusDto,
UploadImportCustomsDocumentDto,
} from './dto/import-operations.dto';
import {
DjiboutiIncident,
type DjiboutiIncidentType,
} from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import {
ImportCustomsFinalization,
type ImportCustomsDocumentType,
} from './entities/import-customs-finalization.entity';
const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
'SEAL_BROKEN',
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
];
@Injectable()
export class ImportOperationsService {
constructor(
@InjectRepository(DjiboutiIncident)
private readonly incidents: Repository<DjiboutiIncident>,
@InjectRepository(ImportCustomsFinalization)
private readonly customs: Repository<ImportCustomsFinalization>,
@InjectRepository(EmptyContainerReturn)
private readonly emptyReturns: Repository<EmptyContainerReturn>,
) {}
listIncidents(bookingId?: string) {
return this.incidents.find({
where: bookingId ? { bookingId } : {},
order: { reportedAt: 'DESC', createdAt: 'DESC' } as never,
});
}
async createIncident(dto: CreateDjiboutiIncidentDto) {
const photos = dto.photos ?? [];
if (DAMAGE_INCIDENTS.includes(dto.incidentType) && photos.length === 0) {
throw new BadRequestException('Photos are required for damage-related Djibouti incidents');
}
const incident = await this.incidents.save(
this.incidents.create({
bookingId: dto.bookingId,
containerNumber: dto.containerNumber ?? null,
cargoId: dto.cargoId ?? null,
facility: dto.facility ?? null,
station: dto.station ?? null,
incidentType: dto.incidentType,
description: dto.description,
photos,
reportedBy: dto.reportedBy ?? null,
reportedAt: dto.reportedAt ? new Date(dto.reportedAt) : new Date(),
}),
);
console.log(
`[NOTIFY] Djibouti incident ${incident.incidentType} for booking ${incident.bookingId}; notify Global Logistics Ethiopia and customer.`,
);
console.log(
`[MOVEMENT] Attach incident ${incident.id} to booking ${incident.bookingId} movement history.`,
);
return incident;
}
async getCustoms(bookingId: string) {
return this.getOrCreateCustoms(bookingId);
}
async uploadCustomsDocument(bookingId: string, dto: UploadImportCustomsDocumentDto) {
const row = await this.getOrCreateCustoms(bookingId);
const documents = { ...(row.documents ?? {}), [dto.documentType]: dto.fileId };
await this.customs.update(row.id, {
documents,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
return this.getCustoms(bookingId);
}
async recordDeclaration(bookingId: string, dto: RecordDeclarationDto) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
declarationSerialNumber: dto.declarationSerialNumber,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
return this.getCustoms(bookingId);
}
async notifyDutiesTaxes(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
dutiesTaxesNotifiedAt: row.dutiesTaxesNotifiedAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
console.log(`[NOTIFY] Duties and taxes notification sent for booking ${bookingId}.`);
return this.getCustoms(bookingId);
}
async markDutiesTaxesPaid(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
this.assertDocument(row, 'CUSTOMER_PAYMENT_SLIP', 'Customer payment slip is required before marking duties and taxes paid');
await this.customs.update(row.id, {
dutiesTaxesPaidAt: row.dutiesTaxesPaidAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
return this.getCustoms(bookingId);
}
async assignRisk(bookingId: string, dto: AssignCustomsRiskDto) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
customsRisk: dto.risk,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
console.log(`[NOTIFY] Customs risk ${dto.risk} assigned for booking ${bookingId}; notify customer.`);
return this.getCustoms(bookingId);
}
async markReleasePermitted(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
this.assertReleaseReady(row);
await this.customs.update(row.id, {
importReleasePermittedAt: row.importReleasePermittedAt ?? new Date(),
completedAt: row.completedAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
console.log(`[NOTIFY] Import release permitted for booking ${bookingId}; notify customer.`);
return this.getCustoms(bookingId);
}
listEmptyReturns() {
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
return this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
customerId: dto.customerId ?? null,
returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(),
facility: dto.facility ?? null,
yard: dto.yard ?? null,
zone: dto.zone ?? null,
condition: dto.condition ?? null,
handoverNote: dto.handoverNote ?? null,
performedBy: dto.performedBy ?? null,
}),
);
}
async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {
throw new NotFoundException(`Empty container return ${id} not found`);
}
await this.emptyReturns.update(id, {
status: dto.status,
wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null,
handoverNote: dto.handoverNote ?? row.handoverNote ?? null,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
return this.emptyReturns.findOneOrFail({ where: { id } });
}
private async getOrCreateCustoms(bookingId: string) {
const existing = await this.customs.findOne({ where: { bookingId } });
if (existing) return existing;
return this.customs.save(this.customs.create({ bookingId, documents: {} }));
}
private assertDocument(
row: ImportCustomsFinalization,
type: ImportCustomsDocumentType,
message: string,
) {
if (!row.documents?.[type]) {
throw new BadRequestException(message);
}
}
private assertReleaseReady(row: ImportCustomsFinalization) {
this.assertDocument(row, 'T1_CLOSURE_PROOF', 'T1 closure proof is required before import release');
this.assertDocument(row, 'IMPORT_RELEASE_PERMIT', 'Import release permit upload is required before release is permitted');
if (!row.declarationSerialNumber?.trim()) {
throw new BadRequestException('Declaration serial number is required before import release');
}
if (!row.customsRisk) {
throw new BadRequestException('Customs risk must be assigned before import release');
}
if (!row.dutiesTaxesPaidAt) {
throw new BadRequestException('Duties and taxes must be paid before import release');
}
}
}

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";
@@ -304,6 +310,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

@@ -17,6 +17,7 @@ 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';
@@ -38,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
Container,
TrainSchedulingGlobalRules,
TrainCheckpointEvent,
ImportDjiboutiOperation,
]),
forwardRef(() => BookingsModule),
NotificationsModule,

View File

@@ -151,6 +151,9 @@ describe('TrainSchedulingService', () => {
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,
@@ -97,6 +106,7 @@ 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,
@@ -170,6 +180,7 @@ export class TrainSchedulingService {
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly configService?: ConfigService,
) {}
@@ -767,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) => {
@@ -806,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

View File

@@ -1,5 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { ValidateNested } from 'class-validator';
export class TruckEntranceDto {
@ApiPropertyOptional()
@@ -179,8 +181,11 @@ export class BulkReceiveDto {
@IsUUID('all', { each: true })
bookingIds!: string[];
@ApiProperty({ type: TruckEntranceDto })
truckEntrance!: TruckEntranceDto;
@ApiPropertyOptional({ type: TruckEntranceDto })
@IsOptional()
@ValidateNested()
@Type(() => TruckEntranceDto)
truckEntrance?: TruckEntranceDto;
@ApiPropertyOptional()
@IsOptional()

View File

@@ -1,5 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { ValidateNested } from 'class-validator';
import { TruckEntranceDto } from './bulk-receive.dto';
export class ReceiveWarehouseInventoryDto {
@@ -57,6 +59,8 @@ export class ReceiveWarehouseInventoryDto {
notes?: string;
@ApiProperty({ type: TruckEntranceDto })
@ValidateNested()
@Type(() => TruckEntranceDto)
truckEntrance!: TruckEntranceDto;
@ApiPropertyOptional()

View File

@@ -86,6 +86,12 @@ export class WarehouseInventoryController {
return this.inventoryService.readyToLoadExport();
}
@Get('received-export')
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
receivedExport() {
return this.inventoryService.receivedExport();
}
@Get('loaded-export')
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
loadedExport() {

View File

@@ -842,8 +842,12 @@ export class WarehouseInventoryService {
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const truckEntrance = this.mergeSystemTruckEntrance(dto.truckEntrance, booking);
this.assertTruckEntrance(truckEntrance);
const truckEntrance = dto.truckEntrance
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
: undefined;
if (dto.direction === 'EXPORT') {
this.assertTruckEntrance(truckEntrance);
}
const receiveNote = this.buildReceiveNote({
grnNumber,
direction: dto.direction,
@@ -856,7 +860,7 @@ export class WarehouseInventoryService {
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: 1,
quantity: Number(booking.containerQuantity) || 1,
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: now,
@@ -869,16 +873,18 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`,
description: truckEntrance?.truckPlateNumber
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
performedBy: dto.performedBy,
},
manager,
);
await this.notifyOwnerInventoryReceived({
phone: truckEntrance.customerPhone,
ownerName: truckEntrance.ownerName,
bookingReference: truckEntrance.edrDigitalBookingId,
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
ownerName: truckEntrance?.ownerName ?? booking.customer,
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
@@ -982,6 +988,11 @@ export class WarehouseInventoryService {
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
}
/** EXPORT inventory received at the facility and awaiting inspection. */
async receivedExport(): Promise<ReadyToLoadRow[]> {
return this.exportInventoryByStatus('RECEIVED');
}
/** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
async loadedExport(): Promise<ReadyToLoadRow[]> {
return this.exportInventoryByStatus('LOADED');
@@ -1592,19 +1603,27 @@ export class WarehouseInventoryService {
}
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
const weight = Number(dto.weight) || 0;
const volume = Number(dto.volume) || 0;
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
const id = await this.dataSource.transaction(async (manager) => {
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
if (dto.bookingId) {
await this.assertBookingExists(manager, dto.bookingId);
const bookingSource = dto.bookingId
? await this.getBookingTruckEntranceSource(manager, dto.bookingId)
: null;
if (dto.bookingId && !bookingSource?.reference) {
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
}
const quantity = bookingSource
? Number(bookingSource.containerQuantity) || 1
: Number(dto.quantity) || 0;
const weight = bookingSource
? Number(bookingSource.weight) || 0
: Number(dto.weight) || 0;
const volume = Number(dto.volume) || 0;
const containerCount = dto.containerId ? Math.round(quantity) : 0;
const truckEntrance = dto.bookingId
? this.mergeSystemTruckEntrance(dto.truckEntrance, await this.getBookingTruckEntranceSource(manager, dto.bookingId))
? this.mergeSystemTruckEntrance(dto.truckEntrance, bookingSource ?? {})
: dto.truckEntrance;
this.assertTruckEntrance(truckEntrance);
@@ -1628,7 +1647,7 @@ export class WarehouseInventoryService {
cargoId: dto.cargoId ?? null,
containerId: dto.containerId ?? null,
goodsId: dto.goodsId ?? null,
quantity: Number(dto.quantity) || 0,
quantity,
weight,
volume: dto.volume ?? null,
status: 'RECEIVED',
@@ -2619,16 +2638,6 @@ export class WarehouseInventoryService {
return { warehouse, yard, zone };
}
private async assertBookingExists(manager: EntityManager, bookingId: string): Promise<void> {
const rows = await manager.query(
'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
[bookingId],
);
if (!rows || rows.length === 0) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
private appendNote(existing: string | null | undefined, note: string): string {
const trimmed = existing?.trim();
return trimmed ? `${trimmed}\n${note}` : note;
@@ -2872,42 +2881,42 @@ export class WarehouseInventoryService {
grnNumber: string;
direction?: string | null;
notes?: string | null;
truckEntrance: TruckEntranceDto;
truckEntrance?: TruckEntranceDto;
}): string {
const truck = input.truckEntrance;
const rows = [
`GRN Number: ${input.grnNumber}`,
input.direction ? `Direction: ${input.direction}` : null,
truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
truck.tin ? `TIN: ${truck.tin}` : null,
truck.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
`Truck Plate: ${truck.truckPlateNumber}`,
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
truck.truckType ? `Truck Type: ${truck.truckType}` : null,
`Driver: ${truck.driverName}`,
`Driver Phone: ${truck.driverPhone}`,
truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
`Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`,
truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
truck.incoterms ? `Incoterms: ${truck.incoterms}` : null,
truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
truck.itemCode ? `Item Code: ${truck.itemCode}` : null,
truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
truck?.tin ? `TIN: ${truck.tin}` : null,
truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null,
truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
truck?.truckType ? `Truck Type: ${truck.truckType}` : null,
truck?.driverName ? `Driver: ${truck.driverName}` : null,
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
truck?.itemCode ? `Item Code: ${truck.itemCode}` : null,
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
];
return rows.filter(Boolean).join('\n');

View File

@@ -124,6 +124,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseAllocationService,
WarehouseFeeService,
WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService,
],
})
export class WarehousesModule {}