Import operation gate pass

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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