merge conflict

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

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
@@ -44,32 +44,59 @@ export class FirstMileService {
* paid before any first-mile work proceeds. Throws if the reference is
* unknown or the booking has not reached PAID status.
*/
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findById(bookingId);
async acceptBooking(bookingId: string): Promise<FirstMile> {
const booking = await this.bookingsRepository.findById(bookingId, {
relations: { serviceType: true },
});
if (!booking) {
return null;
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if (booking.paymentStatus !== 'PAID') {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
return this.acceptEligibleBooking(booking);
}
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
const [booking] = await this.bookingsRepository.findAll({
where: { reference: bookingReference },
relations: { serviceType: true },
take: 1,
});
if (!booking) {
return null;
throw new NotFoundException(`Booking ${bookingReference} not found`);
}
return this.acceptEligibleBooking(booking);
}
/**
* Shared accept path: validates payment + first-mile eligibility, rejects an
* already-assigned booking, then creates the first-mile record. Throws a
* meaningful HTTP error instead of returning null so the client can surface
* why an accept was refused.
*/
private async acceptEligibleBooking(booking: {
id: string;
reference?: string;
paymentStatus?: string | null;
tradeDirection?: string | null;
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): Promise<FirstMile> {
const label = booking.reference ?? booking.id;
if (booking.paymentStatus !== 'PAID') {
return null;
throw new BadRequestException(`Booking ${label} is not paid`);
}
if (!this.bookingRequestsFirstMile(booking)) {
throw new BadRequestException(`Booking ${label} does not require a first mile`);
}
const existing = await this.findByBookingId(booking.id);
if (existing) {
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
}
return this.create({
@@ -131,6 +158,11 @@ export class FirstMileService {
}
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
const existing = await this.findByBookingId(dto.bookingId);
if (existing) {
return existing;
}
return this.firstMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
@@ -142,6 +174,32 @@ export class FirstMileService {
});
}
private async findByBookingId(bookingId: string): Promise<FirstMile | null> {
const [records] = await this.firstMileRepository.findAndCount({
where: { bookingId },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
take: 1,
});
return records[0] ?? null;
}
private bookingRequestsFirstMile(booking: {
tradeDirection?: string | null;
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
// Export bookings always need a first mile (pickup → origin yard); the
// pickup address is captured at assignment time, not required upfront.
return Boolean(
booking.tradeDirection === 'EXPORT' ||
booking.firstMilePickupAddress?.trim() ||
booking.serviceType?.includesFirstMile,
);
}
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
const existing = await this.findById(id);
@@ -167,6 +225,16 @@ export class FirstMileService {
return updated;
}
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
const updated = await this.firstMileRepository.update(id, { status });
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
return updated;
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);

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

@@ -265,12 +265,12 @@ export class InterchangeDocumentsService {
)
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CONTAINER' AS "itemType",
'CONTAINER'::varchar AS "itemType",
COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL::uuid AS "bookingCargoId",
COALESCE(c.container_number, bc.container_number) AS "containerNumber",
c.seal_number AS "sealNumber",
NULL AS "cargoId",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
@@ -288,12 +288,12 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CONTAINER' AS "itemType",
'CONTAINER'::varchar AS "itemType",
bc.id AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL::uuid AS "bookingCargoId",
bc.container_number AS "containerNumber",
NULL AS "sealNumber",
NULL AS "cargoId",
NULL::varchar AS "sealNumber",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
@@ -314,11 +314,11 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CARGO' AS "itemType",
NULL AS "bookingContainerId",
'CARGO'::varchar AS "itemType",
NULL::uuid AS "bookingContainerId",
cg.id AS "bookingCargoId",
NULL AS "containerNumber",
NULL AS "sealNumber",
NULL::varchar AS "containerNumber",
NULL::varchar AS "sealNumber",
cg.id AS "cargoId",
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
@@ -337,12 +337,12 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
NULL AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL AS "containerNumber",
NULL AS "sealNumber",
NULL AS "cargoId",
(CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType",
NULL::uuid AS "bookingContainerId",
NULL::uuid AS "bookingCargoId",
NULL::varchar AS "containerNumber",
NULL::varchar AS "sealNumber",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
a.cargo_total_weight_vgm AS "weight",

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
@@ -13,7 +13,7 @@ import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile]),
BookingsModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,

View File

@@ -32,6 +32,7 @@ export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,4 +49,12 @@ export class CreateVehicleDto {
@IsOptional()
@IsString()
trailerPlateNo?: string;
@IsOptional()
@IsNumber()
estimatedDistanceKm?: number;
@IsOptional()
@IsNumber()
actualDistanceKm?: number;
}

View File

@@ -62,4 +62,10 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'assigned_driver_name', nullable: true })
assignedDriverName?: string;
@Column({ name: 'estimated_distance_km', type: 'numeric', nullable: true })
estimatedDistanceKm?: number;
@Column({ name: 'actual_distance_km', type: 'numeric', nullable: true })
actualDistanceKm?: number;
}

View File

@@ -1,5 +1,161 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
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()
@IsOptional()
@IsString()
ownerName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
consigneeDetails?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
edrDigitalBookingId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
tin?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
customerPhone?: string;
@ApiProperty()
@IsString()
truckPlateNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
trailerPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
assignedEquipmentNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
customsSealNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
declarationNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
incoterms?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
hsCodes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
itemCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
itemDescription?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
packagingType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
unitCount?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
grossWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
netWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
volumeDimensions?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
conditionAtReceipt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
damagedRejectedQuantity?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
warehouseCodeLocation?: string;
@ApiProperty()
@IsString()
driverName!: string;
@ApiProperty()
@IsString()
driverPhone!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverLicenseNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckType?: string;
@ApiProperty()
@IsNumber()
@Min(0)
entranceTareWeightKg!: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
exitTareWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverSignatoryName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
warehouseManagerName?: string;
}
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
export class BulkReceiveDto {
@@ -25,6 +181,12 @@ export class BulkReceiveDto {
@IsUUID('all', { each: true })
bookingIds!: string[];
@ApiPropertyOptional({ type: TruckEntranceDto })
@IsOptional()
@ValidateNested()
@Type(() => TruckEntranceDto)
truckEntrance?: TruckEntranceDto;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -33,4 +33,14 @@ export class PayInvoiceBodyDto {
@IsOptional()
@IsString()
reference?: string;
@ApiPropertyOptional({ description: 'Pickup driver name to notify after payment' })
@IsOptional()
@IsString()
driverName?: string;
@ApiPropertyOptional({ description: 'Pickup driver phone to notify after payment' })
@IsOptional()
@IsString()
driverPhone?: string;
}

View File

@@ -1,5 +1,8 @@
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 {
@ApiProperty({ format: 'uuid' })
@@ -55,6 +58,11 @@ export class ReceiveWarehouseInventoryDto {
@IsString()
notes?: string;
@ApiProperty({ type: TruckEntranceDto })
@ValidateNested()
@Type(() => TruckEntranceDto)
truckEntrance!: TruckEntranceDto;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString } from 'class-validator';
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
/** Records a DO / release order being sent to the customer for import pickup. */
export class ReleaseOrderDto {
@@ -17,4 +17,77 @@ export class ReleaseOrderDto {
@IsOptional()
@IsString()
performedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
bookingId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
customerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
trailerPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
gateInTime?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
tareWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
grossWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
netWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
gateOutTime?: string;
}

View File

@@ -21,6 +21,9 @@ export interface ImportTrainRow {
totalBookings: number;
totalContainers: number;
totalCargoes: number;
unloadedBookings: number;
pendingUnloadBookings: number;
fullyUnloaded: boolean;
status: string;
}
@@ -34,6 +37,7 @@ export interface ImportTrainItemRow {
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
inspectionStatus: string | null;
lastMileRequested: boolean;
pickupOption: string;
}
@@ -133,7 +137,7 @@ export class SchedulingReadFacade {
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
FROM freight.wagons
WHERE deleted_at IS NULL
AND status NOT IN ('RETIRED', 'MAINTENANCE')
AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE')
ORDER BY wagon_number ASC`,
);
}
@@ -205,7 +209,24 @@ export class SchedulingReadFacade {
WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers",
(SELECT count(*) FROM freight.cargoes cg
JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes"
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes",
(SELECT count(*) FROM freight.train_schedule_bookings tsbp
JOIN freight.bookings bp ON bp.id = tsbp.booking_id AND bp.deleted_at IS NULL
WHERE tsbp.train_schedule_id = ts.id
AND tsbp.deleted_at IS NULL
AND bp.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')
AND (
NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory invp
WHERE invp.booking_id = bp.id AND invp.deleted_at IS NULL
)
OR EXISTS (
SELECT 1 FROM freight.warehouse_inventory invr
WHERE invr.booking_id = bp.id
AND invr.deleted_at IS NULL
AND invr.status = 'RECEIVED'
)
)) AS "pendingUnloadBookings"
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
@@ -219,13 +240,22 @@ export class SchedulingReadFacade {
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
...rest,
totalBookings: Number(rest.totalBookings) || 0,
totalContainers: Number(rest.totalContainers) || 0,
totalCargoes: Number(rest.totalCargoes) || 0,
route: rest.origin || rest.destination ? `${rest.origin ?? '?'}${rest.destination ?? '?'}` : null,
}));
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => {
const totalBookings = Number(rest.totalBookings) || 0;
const pendingUnloadBookings = Number(rest.pendingUnloadBookings) || 0;
const unloadedBookings = Math.max(totalBookings - pendingUnloadBookings, 0);
return {
...rest,
totalBookings,
totalContainers: Number(rest.totalContainers) || 0,
totalCargoes: Number(rest.totalCargoes) || 0,
unloadedBookings,
pendingUnloadBookings,
fullyUnloaded: totalBookings > 0 && pendingUnloadBookings === 0,
route: rest.origin || rest.destination ? `${rest.origin ?? '?'}${rest.destination ?? '?'}` : null,
};
});
}
/** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */
@@ -242,6 +272,7 @@ export class SchedulingReadFacade {
b.cargo_total_weight_vgm AS "weight",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
COALESCE(inv.status, b.status) AS "currentStatus",
inv.inspection_status AS "inspectionStatus",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption"
@@ -281,7 +312,15 @@ export class SchedulingReadFacade {
];
params.push(
filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'],
['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'],
[
'LOADED',
'DISPATCHED',
'IN_TRANSIT',
'ARRIVED_AT_DJIBOUTI',
'ARRIVED_AT_PORT',
'ARRIVED_AT_DESTINATION',
'UNLOADED_AT_DJIBOUTI_PORT',
],
);
if (filter.scheduleId) {
@@ -290,7 +329,7 @@ export class SchedulingReadFacade {
}
if (filter.destination) {
params.push(`%${filter.destination}%`);
where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`);
where.push(`(dy.code ILIKE $${params.length} OR dy.label ILIKE $${params.length})`);
}
if (filter.dateFrom) {
params.push(filter.dateFrom);
@@ -312,7 +351,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.name AS "destinationName",
dy.label AS "destinationName",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
ts.scheduled_departure_date AS "departureTime",

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

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -54,6 +55,26 @@ export class WarehouseInvoiceController {
return this.invoiceService.findById(id);
}
@Get('warehouse-fee-invoices/:id/document')
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.document(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get('warehouse-fee-invoices/:id/receipt')
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.receipt(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Patch('warehouse-fee-invoices/:id/cancel')
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
cancel(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -1,6 +1,7 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { NotificationsService } from '../notifications/notifications.service';
import {
WarehouseFeeInvoice,
WarehouseInvoiceStatus,
@@ -10,6 +11,7 @@ import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
interface GenerateOptions {
confirmZero?: boolean;
@@ -21,19 +23,41 @@ export interface PayInvoiceDto {
amount: number;
method?: string;
reference?: string;
driverName?: string;
driverPhone?: string;
}
/** Invoices that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
export interface InvoiceDocumentDetails {
bookingReference: string | null;
customerName: string | null;
inventoryReference: string | null;
inventoryInfo: string | null;
inventoryStatus: string | null;
containerNumber: string | null;
cargoDescription: string | null;
clearanceStatus: string;
warehouseName: string | null;
yardName: string | null;
zoneName: string | null;
}
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
@Injectable()
export class WarehouseInvoiceService {
private readonly logger = new Logger(WarehouseInvoiceService.name);
constructor(
private readonly dataSource: DataSource,
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
private readonly feeService: WarehouseFeeService,
private readonly documents: WarehouseReleaseDocumentService,
private readonly notifications: NotificationsService,
) {}
// ── Generation ───────────────────────────────────────────────────────────
@@ -132,7 +156,9 @@ export class WarehouseInvoiceService {
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
}
return this.findById(invoice.id);
const saved = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(saved);
return saved;
}
/** WHF-YYYYMMDD-00001 — sequential per day. */
@@ -150,11 +176,35 @@ export class WarehouseInvoiceService {
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoice & { items: unknown[] }> {
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] };
const details = await this.getInvoiceDocumentDetails(invoice);
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
}
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
return {
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
}
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException('A receipt is available only after payment is recorded.');
}
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
return {
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
@@ -204,7 +254,9 @@ export class WarehouseInvoiceService {
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
payments,
});
return updated as WarehouseFeeInvoice;
const paidInvoice = updated as WarehouseFeeInvoice;
await this.notifyWarehouseFeePayment(paidInvoice, dto);
return paidInvoice;
}
// ── Release blocking ──────────────────────────────────────────────────────
@@ -213,4 +265,326 @@ export class WarehouseInvoiceService {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
}
async assertClearanceAllowed(inventoryId: string): Promise<void> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
);
}
if (invoices.some((inv) => inv.status === 'PAID')) return;
const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
if (payableAmount > 0) {
throw new BadRequestException(
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
);
}
}
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
CONCAT_WS(
' / ',
NULLIF(inv.status, ''),
NULLIF(COALESCE(container.container_number, booking_container.container_number), ''),
NULLIF(COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description), '')
) AS "inventoryInfo",
wh.name AS "warehouseName",
yard.name AS "yardName",
zone.name AS "zoneName",
CASE
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
ELSE 'PENDING PAYMENT'
END AS "clearanceStatus"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
WHERE fee.id = $1
LIMIT 1`,
[invoice.id, invoice.status],
);
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
inventoryReference: row?.inventoryReference ?? null,
inventoryInfo: row?.inventoryInfo ?? null,
inventoryStatus: row?.inventoryStatus ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
warehouseName: row?.warehouseName ?? null,
yardName: row?.yardName ?? null,
zoneName: row?.zoneName ?? null,
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
};
}
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
bookingReference: string | null;
customerName: string | null;
customerPhone: string | null;
driverName: string | null;
driverPhone: string | null;
containerNumber: string | null;
cargoDescription: string | null;
}> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
last_vehicle.assigned_driver_name,
NULLIF(TRIM(CONCAT(COALESCE(first_driver.first_name, ''), ' ', COALESCE(first_driver.last_name, ''))), ''),
first_vehicle.assigned_driver_name
) AS "driverName",
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN LATERAL (
SELECT lm.vehicle_id
FROM freight.last_mile lm
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL
ORDER BY lm.created_at DESC
LIMIT 1
) latest_last_mile ON true
LEFT JOIN freight.vehicles last_vehicle ON last_vehicle.id = latest_last_mile.vehicle_id
LEFT JOIN freight.drivers last_driver ON last_driver.id = last_vehicle.assigned_driver_id
LEFT JOIN LATERAL (
SELECT fm.vehicle_id
FROM freight.first_mile fm
WHERE fm.booking_id = b.id AND fm.deleted_at IS NULL
ORDER BY fm.created_at DESC
LIMIT 1
) latest_first_mile ON true
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
WHERE fee.id = $1
LIMIT 1`,
[invoice.id],
);
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
customerPhone: row?.customerPhone ?? null,
driverName: row?.driverName ?? null,
driverPhone: row?.driverPhone ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
};
}
private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise<void> {
const phone = recipient?.trim();
if (!phone) return;
try {
await this.notifications.directSend('sms', phone, message);
} catch (error) {
this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`);
}
}
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const cargo = contacts.containerNumber || contacts.cargoDescription;
const cargoText = cargo ? ` Cargo: ${cargo}.` : '';
const message =
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` +
`${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
}
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const statusText =
invoice.status === 'PAID'
? 'fully paid and ready for pickup release'
: `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
const customerMessage =
`Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
`was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;
await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`);
if (invoice.status !== 'PAID') return;
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver';
const cargo = contacts.containerNumber || contacts.cargoDescription;
const driverMessage =
`Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
(contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') +
(cargo ? ` Cargo: ${cargo}.` : '') +
' Proceed with pickup after gate verification.';
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
}
private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT',
details: InvoiceDocumentDetails,
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const money = (amount: unknown, currency = invoice.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const items = invoice.items as Array<{
id?: string;
description?: string;
feeType?: string;
quantity?: number;
unitRate?: number;
amount?: number;
currency?: string;
chargeableDays?: number | null;
}>;
const lastPayment = [...(invoice.payments ?? [])].pop();
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(invoice.invoiceNumber)}</strong>
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
<div><span>Customer</span>${esc(details.customerName)}</div>
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Fee type</th>
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${items
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
</tr>`,
)
.join('')}
</tbody>
</table>
<div class="totals">
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
</div>
<div class="footer">
<div class="line">Prepared by EDR warehouse finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
private safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
}

View File

@@ -0,0 +1,302 @@
import { existsSync } from 'fs';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
const MIN_VALID_PDF_BYTES = 2_000;
const RELEASE_DOCUMENT_PRINT_STYLES = `
<style id="warehouse-release-document-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
@Injectable()
export class WarehouseReleaseDocumentService {
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
}
this.logger.log(
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('warehouse-release-document-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
}
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((path) => existsSync(path));
}
private isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
}
private htmlToBasicPdfBuffer(html: string): Buffer {
const doc = this.extractReleaseDocument(html);
const body: string[] = [
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8),
this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2),
...this.wrapLines(doc.notice, 68)
.slice(0, 4)
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'),
];
let y = 586;
const rowHeight = 20;
for (const [label, value] of doc.rows.slice(0, 14)) {
body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6));
body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6));
body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16'));
body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16'));
y -= rowHeight;
}
body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18'));
body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7));
body.push(
...this.wrapLines(doc.clause, 92)
.slice(0, 4)
.map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')),
);
const stream = [
...body,
this.lineOp(36, 60, 218, 60, '0 0 0', 1),
this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'),
this.circularSealOps(286, 62, 38),
this.lineOp(341, 60, 559, 60, '0 0 0', 1),
this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'),
].join('\n');
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>',
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, 'latin1'));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
pdf += '% fallback padding\n';
}
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += '0000000000 65535 f \n';
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, 'latin1');
}
private extractReleaseDocument(html: string): {
reference: string;
issuedAt: string;
notice: string;
clause: string;
rows: Array<[string, string]>;
} {
const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim();
const reference = textFromHtml(html.match(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO');
const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-');
const notice = textFromHtml(
html.match(/<div class="notice">([\s\S]*?)<\/div>/i)?.[1] ??
'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.',
);
const clause = textFromHtml(
html.match(/<div class="clause">([\s\S]*?)<\/div>/i)?.[1] ??
'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.',
);
const rows: Array<[string, string]> = [];
for (const match of html.matchAll(/<tr><th>([\s\S]*?)<\/th><td>([\s\S]*?)<\/td><\/tr>/gi)) {
rows.push([textFromHtml(match[1]), textFromHtml(match[2])]);
}
return { reference, issuedAt, notice, clause, rows };
}
private htmlToPlainText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n');
}
private wrapLines(text: string, width: number): string[] {
const wrapped: string[] = [];
for (const rawLine of text.split('\n')) {
const words = rawLine.split(' ');
let line = '';
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > width && line) {
wrapped.push(line);
line = word;
} else {
line = next;
}
}
if (line) wrapped.push(line);
}
return wrapped.length ? wrapped : ['Warehouse release document'];
}
private escapePdfText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
private textOp(
text: string,
x: number,
y: number,
size: number,
font: 'F1' | 'F2' = 'F1',
color = '0 0 0',
): string {
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
}
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string {
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
}
private rectOp(
x: number,
y: number,
width: number,
height: number,
fillColor = '1 1 1',
strokeColor = '0.08 0.32 0.18',
lineWidth = 0.8,
): string {
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
}
private circularSealOps(cx: number, cy: number, radius = 51): string {
return [
'q',
'0.08 0.32 0.18 RG',
'0.08 0.32 0.18 rg',
'2.2 w',
this.circlePath(cx, cy, radius),
'S',
'0.8 w',
this.circlePath(cx, cy, radius - 10),
'S',
this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'),
this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'),
this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'),
'Q',
].join('\n');
}
private circlePath(cx: number, cy: number, r: number): string {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
'h',
].join('\n');
}
}

View File

@@ -1,12 +1,12 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@@ -32,6 +32,7 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseInventoryService } from './warehouse-inventory.service';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseLoadingsController } from './warehouse-loadings.controller';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository';
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
@@ -70,7 +71,8 @@ import { WarehousesService } from './warehouses.service';
]),
FilesModule,
InterchangeDocumentsModule,
LastMileModule,
forwardRef(() => LastMileModule),
NotificationsModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
@@ -111,8 +113,8 @@ import { WarehousesService } from './warehouses.service';
WarehouseFeeService,
WarehouseInvoiceService,
WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService,
SchedulingReadFacade,
ContractPdfService,
],
exports: [
WarehousesService,
@@ -122,6 +124,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseAllocationService,
WarehouseFeeService,
WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService,
],
})
export class WarehousesModule {}