mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
merge conflict
This commit is contained in:
@@ -52,8 +52,10 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
@@ -67,6 +69,7 @@ import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -130,6 +133,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
@@ -145,6 +149,8 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -161,6 +167,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -179,6 +186,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
|
||||
@@ -80,8 +80,15 @@ export class ContractPdfService {
|
||||
this.logger.error(
|
||||
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
|
||||
);
|
||||
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
|
||||
if (this.isValidPdf(fallback)) {
|
||||
this.logger.warn(
|
||||
`Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,4 +120,85 @@ export class ContractPdfService {
|
||||
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||
);
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
const text = this.htmlToPlainText(html);
|
||||
const lines = this.wrapLines(text, 92).slice(0, 72);
|
||||
const body = lines
|
||||
.map((line, index) => {
|
||||
const prefix = index === 0 ? '50 790 Td' : '0 -12 Td';
|
||||
return `${prefix} (${this.escapePdfText(line)}) Tj`;
|
||||
})
|
||||
.join('\n');
|
||||
const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`;
|
||||
|
||||
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 >> >> /Contents 5 0 R >>',
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
`<< /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 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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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 : ['Document'];
|
||||
}
|
||||
|
||||
private escapePdfText(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export class CreateWarehouseModule1790000000000 implements MigrationInterface {
|
||||
weight NUMERIC(14,3) NOT NULL DEFAULT 0,
|
||||
volume NUMERIC(12,3) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE',
|
||||
inspection_status VARCHAR(20) NULL,
|
||||
arrived_at TIMESTAMPTZ NULL,
|
||||
inspected_at TIMESTAMPTZ NULL,
|
||||
ready_for_loading_at TIMESTAMPTZ NULL,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fix for fresh deployments: AddWarehouseInspection1750000000003 runs before
|
||||
* the warehouse_inventory table exists, so it cannot add inspection_status.
|
||||
*/
|
||||
export class AddWarehouseInventoryInspectionStatusFix1791000000005 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if ((await queryRunner.hasTable(this.table)) && !(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if ((await queryRunner.hasTable(this.table)) && (await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.dropColumn(this.table, 'inspection_status');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Catch-up for environments where AddWarehouseInspection ran before the
|
||||
* warehouse module table existed. Production needs this column for unload and
|
||||
* inspection flows because the WarehouseInventory entity maps inspectionStatus.
|
||||
*/
|
||||
export class EnsureWarehouseInventoryInspectionStatus1821000000001 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({
|
||||
name: 'inspection_status',
|
||||
type: 'varchar',
|
||||
length: '20',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_inspection_status
|
||||
ON freight.warehouse_inventory(inspection_status)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_warehouse_inventory_inspection_status
|
||||
`);
|
||||
|
||||
if (await queryRunner.hasColumn(this.table, 'inspection_status')) {
|
||||
await queryRunner.dropColumn(this.table, 'inspection_status');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC,
|
||||
ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS estimated_distance_km,
|
||||
DROP COLUMN IF EXISTS actual_distance_km;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface {
|
||||
name = 'CreateImportDjiboutiOperations1822000000000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'import_djibouti_operations',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'train_schedule_id', type: 'uuid', isUnique: true },
|
||||
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'load_list_generated_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.import_djibouti_operations',
|
||||
new TableIndex({
|
||||
name: 'idx_import_djibouti_operations_schedule',
|
||||
columnNames: ['train_schedule_id'],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.import_djibouti_operations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['train_schedule_id'],
|
||||
referencedTableName: 'train_schedules',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.import_djibouti_operations', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateImportOperationsTables1823000000000 implements MigrationInterface {
|
||||
name = 'CreateImportOperationsTables1823000000000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'djibouti_import_incidents',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'container_number', type: 'varchar', length: '80', isNullable: true },
|
||||
{ name: 'cargo_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'station', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'incident_type', type: 'varchar', length: '40' },
|
||||
{ name: 'description', type: 'text' },
|
||||
{ name: 'photos', type: 'jsonb', default: "'[]'::jsonb" },
|
||||
{ name: 'reported_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'reported_at', type: 'timestamptz' },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] }));
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] }));
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'import_customs_finalizations',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid', isUnique: true },
|
||||
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'customs_risk', type: 'varchar', length: '12', isNullable: true },
|
||||
{ name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'completed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] }));
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'empty_container_returns',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'container_number', type: 'varchar', length: '80' },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'customer_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'return_date', type: 'timestamptz' },
|
||||
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'yard', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'zone', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'condition', type: 'text', isNullable: true },
|
||||
{ name: 'handover_note', type: 'text', isNullable: true },
|
||||
{ name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" },
|
||||
{ name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] }));
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] }));
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.empty_container_returns', true);
|
||||
await queryRunner.dropTable('freight.import_customs_finalizations', true);
|
||||
await queryRunner.dropTable('freight.djibouti_import_incidents', true);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -49,6 +49,15 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import {
|
||||
ImportDjiboutiOperation,
|
||||
type ImportDjiboutiDocumentType,
|
||||
} from './entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from './dto/import-djibouti-operation.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
@@ -95,6 +104,9 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
@@ -167,6 +179,8 @@ export class TrainSchedulingService {
|
||||
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
|
||||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -602,6 +616,74 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
private async runWarehouseArrivalAutomation(scheduleId: string) {
|
||||
const [schedule]: Array<{
|
||||
originCountry: string | null;
|
||||
destinationCountry: string | null;
|
||||
destinationCode: string | null;
|
||||
destinationName: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
dy.code AS "destinationCode",
|
||||
dy.name AS "destinationName"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.id = $1 AND ts.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
|
||||
|
||||
const direction = deriveTradeDirection(
|
||||
{ country: schedule.originCountry },
|
||||
{ country: schedule.destinationCountry },
|
||||
);
|
||||
|
||||
try {
|
||||
if (direction === 'IMPORT') {
|
||||
return {
|
||||
direction,
|
||||
action: 'IMPORT_AUTO_UNLOAD',
|
||||
status: 'COMPLETED',
|
||||
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
||||
scheduleId,
|
||||
'SYSTEM_TRAIN_ARRIVAL',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
||||
return {
|
||||
direction,
|
||||
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
||||
status: 'COMPLETED',
|
||||
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
||||
scheduleId,
|
||||
'SYSTEM_TRAIN_ARRIVAL',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { direction, status: 'SKIPPED', reason: 'No warehouse arrival automation for this route' };
|
||||
} catch (error) {
|
||||
return {
|
||||
direction,
|
||||
status: 'FAILED',
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
|
||||
const normalized = (value ?? '').toUpperCase();
|
||||
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
|
||||
normalized.includes(token),
|
||||
);
|
||||
}
|
||||
|
||||
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
@@ -696,6 +778,7 @@ export class TrainSchedulingService {
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -735,9 +818,555 @@ export class TrainSchedulingService {
|
||||
.execute();
|
||||
});
|
||||
|
||||
if (this.isImportDjiboutiSchedule(schedule)) {
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
|
||||
});
|
||||
console.log(
|
||||
`[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async getImportDjiboutiOperation(scheduleId: string) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
return this.mapImportDjiboutiOperation(schedule, operation);
|
||||
}
|
||||
|
||||
async uploadImportDjiboutiDocument(
|
||||
scheduleId: string,
|
||||
dto: UploadImportDjiboutiDocumentDto,
|
||||
) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const documents = {
|
||||
...(operation.documents ?? {}),
|
||||
[dto.documentType]: {
|
||||
fileId: dto.fileId ?? null,
|
||||
fileUrl: dto.fileUrl ?? null,
|
||||
reference: dto.reference ?? null,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: dto.performedBy ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
documents,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const missing = this.missingImportDjiboutiDocuments(operation);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`,
|
||||
);
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
if (!operation.loadedOnTrainAt) {
|
||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
||||
}
|
||||
|
||||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||||
await this.dispatchSchedule(schedule.id);
|
||||
} else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||||
throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
|
||||
}
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const generatedAt = operation.loadListGeneratedAt ?? new Date();
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
loadListGeneratedAt: generatedAt,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
route: schedule.route?.name ?? null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
})),
|
||||
})),
|
||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||
};
|
||||
}
|
||||
|
||||
async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const loadList = await this.generateImportLoadList(scheduleId, {
|
||||
performedBy: 'DOCUMENT_GENERATION',
|
||||
});
|
||||
const html = this.buildImportLoadListHtml(loadList);
|
||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
||||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||||
return {
|
||||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isExportSchedule(schedule)) {
|
||||
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
|
||||
}
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule);
|
||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
||||
const reference = schedule.trainNumber ?? schedule.id;
|
||||
return {
|
||||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
private buildExportLoadListHtml(schedule: TrainSchedule): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
const rows = (schedule.trainSet?.wagons ?? [])
|
||||
.flatMap((wagon) =>
|
||||
(wagon.allocations ?? []).map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
|
||||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
<td>${esc(sealNumbers)}</td>
|
||||
</tr>`;
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
||||
(sum, wagon) =>
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Export Marshalling Document</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Export Marshalling Document / Load List</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Train / Schedule
|
||||
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
|
||||
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
|
||||
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
|
||||
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seq</th>
|
||||
<th>Wagon No</th>
|
||||
<th>Wagon Type</th>
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
<th>Seal No</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
Loading and dispatch staff must verify wagon identity, seal number, container number,
|
||||
cargo type, and customer booking against the physical consist before departure.
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Prepared person / date</div>
|
||||
<div class="line">Check person / date</div>
|
||||
<div class="line">Operations authorization / date</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private isExportSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
return direction === 'EXPORT';
|
||||
}
|
||||
|
||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||||
const status = loadList.operation.status;
|
||||
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||
const totalWeight = loadList.wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) =>
|
||||
wagon.allocations.map(
|
||||
(allocation) => `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`,
|
||||
),
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Import Load List / Marshalling Document</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.doc { min-height: 100vh; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
|
||||
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
|
||||
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
|
||||
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
|
||||
.tile strong { font-size: 13px; }
|
||||
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
|
||||
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
|
||||
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
|
||||
.pending { background: #f8fafc; color: #64748b; }
|
||||
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||||
.footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Import Load List /<br />Marshalling Document</h1>
|
||||
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Train / Schedule
|
||||
<strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
|
||||
Generated: ${esc(date(loadList.generatedAt))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
|
||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
|
||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
|
||||
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
|
||||
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
|
||||
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
|
||||
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
|
||||
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
|
||||
</div>
|
||||
|
||||
<h2>Wagon Marshalling Allocation</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seq</th>
|
||||
<th>Wagon</th>
|
||||
<th>Booking</th>
|
||||
<th>Load</th>
|
||||
<th>Container numbers</th>
|
||||
<th class="num">Weight T</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
Gate and loading staff must verify this document against the granted gatepass,
|
||||
railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure.
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Prepared by Djibouti operations</div>
|
||||
<div class="line">Train loading supervisor</div>
|
||||
<div class="line">EDR operations authorization</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private safeDocumentName(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||
}
|
||||
|
||||
private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) return;
|
||||
const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({
|
||||
where: { trainScheduleId: schedule.id },
|
||||
});
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
if (!operation?.loadedOnTrainAt) {
|
||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
||||
}
|
||||
}
|
||||
|
||||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||||
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
return (
|
||||
direction === 'IMPORT' &&
|
||||
this.isDjiboutiPortDestination(
|
||||
`${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
|
||||
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
if (existing) return existing;
|
||||
return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} }));
|
||||
}
|
||||
|
||||
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
|
||||
const documents = operation?.documents ?? {};
|
||||
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]);
|
||||
}
|
||||
|
||||
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
|
||||
if (!operation?.gatepassGrantedAt) {
|
||||
throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted');
|
||||
}
|
||||
}
|
||||
|
||||
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
|
||||
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
|
||||
return {
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
status: {
|
||||
documentsComplete: missingDocuments.length === 0,
|
||||
missingDocuments,
|
||||
gatepassGranted: Boolean(operation.gatepassGrantedAt),
|
||||
readyForLoading: Boolean(operation.readyForLoadingAt),
|
||||
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
|
||||
departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt),
|
||||
loadListGenerated: Boolean(operation.loadListGeneratedAt),
|
||||
},
|
||||
documents: operation.documents ?? {},
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? null,
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
|
||||
loadListGeneratedAt: operation.loadListGeneratedAt ?? null,
|
||||
performedBy: operation.performedBy ?? null,
|
||||
notes: operation.notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a fixed train number on dispatch. The number is drawn from the pool
|
||||
* for the train's dominant cargo type (container vs bulk) and trade direction
|
||||
@@ -1063,7 +1692,9 @@ export class TrainSchedulingService {
|
||||
}
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||
return Object.assign(detail, { warehouseAutomation });
|
||||
}
|
||||
|
||||
async getContainerTrainSchedules() {
|
||||
|
||||
@@ -49,4 +49,12 @@ export class CreateVehicleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trailerPlateNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
estimatedDistanceKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
actualDistanceKm?: number;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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, '-');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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');
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from '../seed/approved-first-lastmile-demo-bookings.seeder';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
const seeder = app.get(ApprovedFirstLastMileDemoBookingsSeeder);
|
||||
await seeder.run();
|
||||
console.log('Approved first/last-mile demo bookings seeded.');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Approved first/last-mile demo booking seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -21,8 +21,18 @@ import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.ent
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
|
||||
const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01';
|
||||
const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'];
|
||||
const DEMO_TRAINS = [
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-01',
|
||||
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
|
||||
arrivalOffsetHours: 1,
|
||||
},
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-02',
|
||||
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
|
||||
arrivalOffsetHours: 2,
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
@@ -44,13 +54,6 @@ async function main() {
|
||||
const scheduleRepo = dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
|
||||
if (existingSchedule) {
|
||||
console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
@@ -83,10 +86,6 @@ async function main() {
|
||||
throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const departure = new Date(now - 6 * 60 * 60 * 1000);
|
||||
const arrival = new Date(now - 60 * 60 * 1000);
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
@@ -97,83 +96,106 @@ async function main() {
|
||||
}),
|
||||
));
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 700,
|
||||
totalLengthMeters: 360,
|
||||
wagonCount: 12,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
const now = Date.now();
|
||||
const seededSchedules: TrainSchedule[] = [];
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: TRAIN_NUMBER,
|
||||
}),
|
||||
);
|
||||
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
|
||||
if (existingSchedule) {
|
||||
console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
seededSchedules.push(existingSchedule);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [index, reference] of BOOKING_REFS.entries()) {
|
||||
const weight = 5200 + index * 800;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
|
||||
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 700 + trainIndex * 80,
|
||||
totalLengthMeters: 360 + trainIndex * 20,
|
||||
wagonCount: 12 + trainIndex,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
warehouseId: warehouse!.id,
|
||||
yardId: warehouseYard!.id,
|
||||
zoneId: warehouseZone!.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: 'DISPATCHED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: new Date(now - 4 * 60 * 60 * 1000),
|
||||
inspectedAt: new Date(now - 3 * 60 * 60 * 1000),
|
||||
readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000),
|
||||
loadedAt: new Date(now - 90 * 60 * 1000),
|
||||
dispatchedAt: new Date(now - 70 * 60 * 1000),
|
||||
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: 'EXPORT',
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType
|
||||
? null
|
||||
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
warehouseId: warehouse!.id,
|
||||
yardId: warehouseYard!.id,
|
||||
zoneId: warehouseZone!.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: 'DISPATCHED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
|
||||
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
|
||||
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
|
||||
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
|
||||
dispatchedAt: departure,
|
||||
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
seededSchedules.push(schedule);
|
||||
}
|
||||
|
||||
console.log('Export Djibouti interchange demo seeded.');
|
||||
console.log(`Train number: ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
for (const schedule of seededSchedules) {
|
||||
console.log(`Train number: ${schedule.trainNumber}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
}
|
||||
console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.');
|
||||
} finally {
|
||||
await app.close();
|
||||
|
||||
219
apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts
Normal file
219
apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
|
||||
const DEMO_TRAINS = [
|
||||
{
|
||||
trainNumber: 'IMP-DJB-NAGAD-01',
|
||||
bookingRefs: ['IMP-DJB-NGD-001', 'IMP-DJB-NGD-002', 'IMP-DJB-NGD-003'],
|
||||
departureOffsetHours: 4,
|
||||
},
|
||||
{
|
||||
trainNumber: 'IMP-DJB-NAGAD-02',
|
||||
bookingRefs: ['IMP-DJB-NGD-004', 'IMP-DJB-NGD-005', 'IMP-DJB-NGD-006'],
|
||||
departureOffsetHours: 8,
|
||||
},
|
||||
];
|
||||
|
||||
function addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
const dataSource = app.get(DataSource);
|
||||
const yardRepo = dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = dataSource.getRepository(ServiceType);
|
||||
const cargoTypeRepo = dataSource.getRepository(CargoType);
|
||||
const locomotiveRepo = dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = dataSource.getRepository(TrainSet);
|
||||
const scheduleRepo = dataSource.getRepository(TrainSchedule);
|
||||
const bookingRepo = dataSource.getRepository(Booking);
|
||||
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
|
||||
const importOperationRepo = dataSource.getRepository(ImportDjiboutiOperation);
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const destinationYard =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'INDODE',
|
||||
label: 'Indode Dry Port',
|
||||
country: 'Ethiopia',
|
||||
isActive: true,
|
||||
displayOrder: 6,
|
||||
}),
|
||||
));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
|
||||
const missing = [
|
||||
!originYard ? 'NAGAD/Djibouti origin yard' : '',
|
||||
!destinationYard ? 'INDODE destination yard' : '',
|
||||
!serviceType ? 'service type' : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`Cannot seed Djibouti-side import demo, missing: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-IMP-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'ICD-DEMO-IMP-LOCO',
|
||||
name: 'Djibouti Import Demo Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'IMPORT_READY',
|
||||
currentYardId: originYard!.id,
|
||||
}),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const seededSchedules: TrainSchedule[] = [];
|
||||
|
||||
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||
const existingSchedule = await scheduleRepo.findOne({
|
||||
where: { trainNumber: demo.trainNumber },
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
console.log(`Djibouti-side import demo already seeded: ${demo.trainNumber}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
seededSchedules.push(existingSchedule);
|
||||
continue;
|
||||
}
|
||||
|
||||
const departure = addHours(now, demo.departureOffsetHours);
|
||||
const arrival = addHours(departure, 12);
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 900 + trainIndex * 120,
|
||||
totalLengthMeters: 430 + trainIndex * 25,
|
||||
wagonCount: 18 + trainIndex * 2,
|
||||
status: 'ASSIGNED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
status: 'SCHEDULED' as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: 'IMPORT',
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||
const weight = 6800 + trainIndex * 900 + bookingIndex * 750;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: departure,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType
|
||||
? null
|
||||
: `Djibouti import demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
priorityScore: 80 - trainIndex * 5 - bookingIndex,
|
||||
trainScheduleId: schedule.id,
|
||||
schedulingStatus: 'SCHEDULED',
|
||||
scheduledAt: now,
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await importOperationRepo.save(
|
||||
importOperationRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
documents: {
|
||||
DELIVERY_ORDER: {
|
||||
reference: `DO-${demo.trainNumber}`,
|
||||
uploadedAt: now.toISOString(),
|
||||
uploadedBy: 'Demo Seeder',
|
||||
notes: 'Demo delivery order for Djibouti-side import flow',
|
||||
},
|
||||
RAILWAY_BILL: {
|
||||
reference: `RB-${demo.trainNumber}`,
|
||||
uploadedAt: now.toISOString(),
|
||||
uploadedBy: 'Demo Seeder',
|
||||
notes: 'Demo railway bill for Djibouti-side import flow',
|
||||
},
|
||||
},
|
||||
performedBy: 'Demo Seeder',
|
||||
notes: '[ICD-DEMO] Nagad to Indode import train for Djibouti-side flow',
|
||||
}),
|
||||
);
|
||||
|
||||
seededSchedules.push(schedule);
|
||||
}
|
||||
|
||||
console.log('Djibouti-side import demo seeded.');
|
||||
console.log(`Corridor: ${originYard!.code} -> ${destinationYard!.code}`);
|
||||
for (const schedule of seededSchedules) {
|
||||
console.log(`Train number: ${schedule.trainNumber}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
console.log(`Backoffice URL: /dashboard/operations/train-scheduling-v2/${schedule.id}`);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Djibouti-side import demo seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
|
||||
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
|
||||
|
||||
function addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dataSource = await AppDataSource.initialize();
|
||||
|
||||
try {
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const yardRepo = manager.getRepository(Yard);
|
||||
const locomotiveRepo = manager.getRepository(Locomotive);
|
||||
const trainSetRepo = manager.getRepository(TrainSet);
|
||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||
|
||||
const negad =
|
||||
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'NEGAD',
|
||||
label: 'Negad',
|
||||
country: 'Djibouti',
|
||||
isActive: true,
|
||||
displayOrder: 5,
|
||||
}),
|
||||
));
|
||||
|
||||
const indode =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'INDODE',
|
||||
label: 'Indode Dry Port',
|
||||
country: 'Ethiopia',
|
||||
isActive: true,
|
||||
displayOrder: 6,
|
||||
}),
|
||||
));
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'NEGAD-INDODE-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'NEGAD-INDODE-LOCO',
|
||||
name: 'Negad to Indode Demo Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
currentYardId: indode.id,
|
||||
}),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const departure = addHours(now, -12);
|
||||
const arrival = now;
|
||||
|
||||
let schedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
|
||||
if (!schedule) {
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 960,
|
||||
totalLengthMeters: 420,
|
||||
wagonCount: 18,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
schedule = scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: negad.id,
|
||||
destinationStationId: indode.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualDepartureAt: departure,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: TRAIN_NUMBER,
|
||||
direction: 'IMPORT',
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
});
|
||||
} else {
|
||||
schedule.originStationId = negad.id;
|
||||
schedule.destinationStationId = indode.id;
|
||||
schedule.scheduledDepartureDate = departure;
|
||||
schedule.scheduledArrivalDate = arrival;
|
||||
schedule.actualDepartureAt = departure;
|
||||
schedule.actualArrivalAt = arrival;
|
||||
schedule.status = 'ARRIVED' as TrainSchedule['status'];
|
||||
schedule.direction = 'IMPORT';
|
||||
schedule.bookingWindowStatus = 'CLOSED';
|
||||
|
||||
if (schedule.trainSetId) {
|
||||
await trainSetRepo.update(schedule.trainSetId, { status: 'COMPLETED' });
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await scheduleRepo.save(schedule);
|
||||
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${saved.id}`);
|
||||
console.log(`Route: ${negad.code} -> ${indode.code}`);
|
||||
});
|
||||
} finally {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Negad to Indode arrived train seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
40
apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts
Normal file
40
apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder';
|
||||
import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder';
|
||||
import { Batch7TestDataSeeder } from '../seed/batch7-test-data.seeder';
|
||||
import { Batch8TestDataSeeder } from '../seed/batch8-test-data.seeder';
|
||||
import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder';
|
||||
import { PricingDataSeeder } from '../seed/pricing-data.seeder';
|
||||
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
await app.get(PricingDataSeeder).run();
|
||||
await app.get(IndodeFacilitySeeder).run();
|
||||
await app.get(Batch14TestDataSeeder).run();
|
||||
await app.get(Batch5TestDataSeeder).run();
|
||||
await app.get(Batch7TestDataSeeder).run();
|
||||
await app.get(Batch8TestDataSeeder).run();
|
||||
await app.get(WarehouseDemoSeeder).run();
|
||||
|
||||
console.log('Warehouse demo data seeded.');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Warehouse demo seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import {
|
||||
Company,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from '../modules/companies/entities/company.entity';
|
||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
|
||||
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_FIRST_LAST';
|
||||
const COMPANY_TIN = 'FLMDEMO001';
|
||||
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
|
||||
|
||||
const YARDS = [
|
||||
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
||||
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
||||
];
|
||||
|
||||
const CONTAINER_TYPES = [
|
||||
{ code: '20FT', label: '20FT', sizeFt: 20 },
|
||||
{ code: '40FT', label: '40FT', sizeFt: 40 },
|
||||
];
|
||||
|
||||
const DEMO_BOOKINGS = [
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-001',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 8,
|
||||
totalWeightTons: 224,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-01T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Doraleh Container Terminal, Djibouti',
|
||||
firstMilePickupLat: 11.5881,
|
||||
firstMilePickupLng: 43.1372,
|
||||
lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.8808,
|
||||
lastMileDeliveryLng: 38.7876,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-002',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 12,
|
||||
totalWeightTons: 240,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-02T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'PK12 Dry Port, Djibouti',
|
||||
firstMilePickupLat: 11.5536,
|
||||
firstMilePickupLng: 43.1103,
|
||||
lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.9137,
|
||||
lastMileDeliveryLng: 38.7815,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-003',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 6,
|
||||
totalWeightTons: 180,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-03T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Djibouti Free Zone Yard',
|
||||
firstMilePickupLat: 11.5947,
|
||||
firstMilePickupLng: 43.1471,
|
||||
lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.9806,
|
||||
lastMileDeliveryLng: 38.8736,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-004',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 10,
|
||||
totalWeightTons: 210,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-04T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Doraleh Multipurpose Port, Djibouti',
|
||||
firstMilePickupLat: 11.6062,
|
||||
firstMilePickupLng: 43.1219,
|
||||
lastMileDeliveryAddress: 'Addis Ababa Freight Terminal',
|
||||
lastMileDeliveryLat: 9.0101,
|
||||
lastMileDeliveryLng: 38.7619,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-005',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 5,
|
||||
totalWeightTons: 150,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-05T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Djibouti Port Gate 3',
|
||||
firstMilePickupLat: 11.5999,
|
||||
firstMilePickupLng: 43.1344,
|
||||
lastMileDeliveryAddress: 'Sebeta Distribution Center',
|
||||
lastMileDeliveryLat: 8.9169,
|
||||
lastMileDeliveryLng: 38.6177,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-001',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 7,
|
||||
totalWeightTons: 196,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-01T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||
firstMilePickupLat: 8.9806,
|
||||
firstMilePickupLng: 38.8736,
|
||||
lastMileDeliveryAddress: 'Doraleh Container Terminal, Djibouti',
|
||||
lastMileDeliveryLat: 11.5881,
|
||||
lastMileDeliveryLng: 43.1372,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-002',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 11,
|
||||
totalWeightTons: 220,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-02T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||
firstMilePickupLat: 8.8808,
|
||||
firstMilePickupLng: 38.7876,
|
||||
lastMileDeliveryAddress: 'Djibouti Free Zone Yard',
|
||||
lastMileDeliveryLat: 11.5947,
|
||||
lastMileDeliveryLng: 43.1471,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-003',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 4,
|
||||
totalWeightTons: 128,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-03T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||
firstMilePickupLat: 8.9137,
|
||||
firstMilePickupLng: 38.7815,
|
||||
lastMileDeliveryAddress: 'Doraleh Multipurpose Port, Djibouti',
|
||||
lastMileDeliveryLat: 11.6062,
|
||||
lastMileDeliveryLng: 43.1219,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-004',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 9,
|
||||
totalWeightTons: 180,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-04T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Addis Ababa Freight Terminal',
|
||||
firstMilePickupLat: 9.0101,
|
||||
firstMilePickupLng: 38.7619,
|
||||
lastMileDeliveryAddress: 'PK12 Dry Port, Djibouti',
|
||||
lastMileDeliveryLat: 11.5536,
|
||||
lastMileDeliveryLng: 43.1103,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-005',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 6,
|
||||
totalWeightTons: 168,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-05T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Sebeta Distribution Center',
|
||||
firstMilePickupLat: 8.9169,
|
||||
firstMilePickupLng: 38.6177,
|
||||
lastMileDeliveryAddress: 'Djibouti Port Gate 3',
|
||||
lastMileDeliveryLat: 11.5999,
|
||||
lastMileDeliveryLng: 43.1344,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ApprovedFirstLastMileDemoBookingsSeeder {
|
||||
private readonly logger = new Logger(ApprovedFirstLastMileDemoBookingsSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Yard).upsert(
|
||||
YARDS.map((yard) => ({ ...yard, isActive: true })),
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(ServiceType).upsert(
|
||||
{
|
||||
code: SERVICE_TYPE_CODE,
|
||||
serviceName: 'Rail Container with First and Last Mile',
|
||||
description: 'Demo service type for approved first/last-mile bookings',
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 10,
|
||||
},
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(ContainerType).upsert(
|
||||
CONTAINER_TYPES.map((containerType, index) => ({
|
||||
...containerType,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: index + 1,
|
||||
})),
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(Company).upsert(
|
||||
{
|
||||
name: 'First and Last Mile Demo Customer',
|
||||
type: CompanyType.Customer,
|
||||
status: CompanyStatus.Active,
|
||||
tin: COMPANY_TIN,
|
||||
vatNumber: COMPANY_TIN,
|
||||
fanNumber: 'FLM0000000000001',
|
||||
country: 'Ethiopia',
|
||||
address: 'Addis Ababa',
|
||||
phone: '251900000101',
|
||||
email: COMPANY_EMAIL,
|
||||
website: null,
|
||||
contactPersonName: 'First Last Mile Demo',
|
||||
contactPersonPhone: '251900000101',
|
||||
generalManagerName: 'Demo Manager',
|
||||
generalManagerEmail: COMPANY_EMAIL,
|
||||
generalManagerPhone: '251900000101',
|
||||
},
|
||||
{ conflictPaths: { tin: true } },
|
||||
);
|
||||
|
||||
const [serviceType, company, yards, containerTypes] = await Promise.all([
|
||||
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||
manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }),
|
||||
manager.getRepository(Yard).find(),
|
||||
manager.getRepository(ContainerType).find(),
|
||||
]);
|
||||
|
||||
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
||||
const containerTypeByCode = new Map(
|
||||
containerTypes.map((containerType) => [containerType.code, containerType]),
|
||||
);
|
||||
|
||||
for (const demoBooking of DEMO_BOOKINGS) {
|
||||
const origin = yardByCode.get(demoBooking.originCode);
|
||||
const destination = yardByCode.get(demoBooking.destinationCode);
|
||||
const containerType = containerTypeByCode.get(demoBooking.containerCode);
|
||||
|
||||
if (!origin || !destination || !containerType) {
|
||||
throw new Error(`approved_first_last_mile_demo_dependency_missing:${demoBooking.reference}`);
|
||||
}
|
||||
|
||||
const wagonsRequired =
|
||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||
|
||||
await manager.getRepository(Booking).upsert(
|
||||
{
|
||||
reference: demoBooking.reference,
|
||||
companyId: company.id,
|
||||
status: 'APPROVED',
|
||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
|
||||
totalAmount: demoBooking.totalWeightTons * 25,
|
||||
paymentStatus: 'PENDING',
|
||||
contractType: 'NEW',
|
||||
serviceTypeId: serviceType.id,
|
||||
firstMilePickupAddress: demoBooking.firstMilePickupAddress,
|
||||
firstMilePickupLat: demoBooking.firstMilePickupLat,
|
||||
firstMilePickupLng: demoBooking.firstMilePickupLng,
|
||||
lastMileDeliveryAddress: demoBooking.lastMileDeliveryAddress,
|
||||
lastMileDeliveryLat: demoBooking.lastMileDeliveryLat,
|
||||
lastMileDeliveryLng: demoBooking.lastMileDeliveryLng,
|
||||
equipmentReturn: 'WITHOUT_RETURN',
|
||||
originYardId: origin.id,
|
||||
destinationYardId: destination.id,
|
||||
tradeDirection: demoBooking.tradeDirection,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
cargoFreeText: 'Demo container cargo',
|
||||
shippingLineId: null,
|
||||
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
paymentCurrency: 'ETB',
|
||||
approvedByStaffAt: new Date(),
|
||||
priorityScore: 20,
|
||||
wagonsRequired,
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
versionNumber: 1,
|
||||
},
|
||||
{ conflictPaths: { reference: true } },
|
||||
);
|
||||
|
||||
const booking = await manager.getRepository(Booking).findOneByOrFail({
|
||||
reference: demoBooking.reference,
|
||||
});
|
||||
|
||||
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
|
||||
await manager.getRepository(BookingContainer).insert({
|
||||
id: randomUUID(),
|
||||
bookingId: booking.id,
|
||||
containerTypeId: containerType.id,
|
||||
quantity: demoBooking.quantity,
|
||||
vgmPerUnitTons,
|
||||
totalVgmTons: demoBooking.totalWeightTons,
|
||||
wagonsRequired,
|
||||
weightLimitRuleId: null,
|
||||
isOverweight: vgmPerUnitTons > 35,
|
||||
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
|
||||
});
|
||||
}
|
||||
|
||||
const bookings = await manager.getRepository(Booking).find({
|
||||
where: { reference: In(DEMO_BOOKINGS.map((booking) => booking.reference)) },
|
||||
select: { id: true },
|
||||
});
|
||||
const bookingIds = bookings.map((booking) => booking.id);
|
||||
|
||||
await manager.getRepository(FirstMile).delete({ bookingId: In(bookingIds) });
|
||||
await manager.getRepository(LastMile).delete({ bookingId: In(bookingIds) });
|
||||
|
||||
await manager.getRepository(FirstMile).insert(
|
||||
bookingIds.map((bookingId) => ({
|
||||
bookingId,
|
||||
status: 'READY_TO_TRANSIT',
|
||||
advancedPayment: 0,
|
||||
remainingPayment: 0,
|
||||
estimatedKm: 18,
|
||||
exactKm: null,
|
||||
vehicleId: null,
|
||||
})),
|
||||
);
|
||||
|
||||
await manager.getRepository(LastMile).insert(
|
||||
bookingIds.map((bookingId) => ({
|
||||
bookingId,
|
||||
status: 'READY_TO_TRANSIT',
|
||||
advancedPayment: 0,
|
||||
remainingPayment: 0,
|
||||
estimatedKm: 22,
|
||||
exactKm: null,
|
||||
vehicleId: null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.log('Seeded 5 import and 5 export approved bookings with first/last-mile legs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
|
||||
const SEED_FLAG = 'SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO';
|
||||
|
||||
const DEMO_TRAINS = [
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-01',
|
||||
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
|
||||
arrivalOffsetHours: 1,
|
||||
},
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-02',
|
||||
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
|
||||
arrivalOffsetHours: 2,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ExportDjiboutiInterchangeDemoSeeder {
|
||||
private readonly logger = new Logger(ExportDjiboutiInterchangeDemoSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||
this.logger.log(`Skipping export Djibouti interchange demo seed because ${SEED_FLAG} is not enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const yardRepo = this.dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
|
||||
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
const locomotiveRepo = this.dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = this.dataSource.getRepository(TrainSet);
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
const destinationYard =
|
||||
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||
(await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
|
||||
const warehouseYard = warehouse
|
||||
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
|
||||
: null;
|
||||
const warehouseZone = warehouseYard
|
||||
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
|
||||
: null;
|
||||
|
||||
const missing = [
|
||||
!originYard ? 'Ethiopian origin yard' : '',
|
||||
!destinationYard ? 'Djibouti destination yard' : '',
|
||||
!serviceType ? 'service type' : '',
|
||||
!warehouse ? 'INDODE_OPEN warehouse' : '',
|
||||
!warehouseYard ? 'warehouse yard' : '',
|
||||
!warehouseZone ? 'warehouse zone' : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (missing.length) {
|
||||
this.logger.warn(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'ICD-DEMO-LOCO',
|
||||
name: 'Interchange Demo Locomotive',
|
||||
maxPullWeightTons: 4000,
|
||||
}),
|
||||
));
|
||||
|
||||
const now = Date.now();
|
||||
let seeded = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
|
||||
if (existingSchedule) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
|
||||
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 700 + trainIndex * 80,
|
||||
totalLengthMeters: 360 + trainIndex * 20,
|
||||
wagonCount: 12 + trainIndex,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: 'EXPORT',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType
|
||||
? null
|
||||
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
warehouseId: warehouse!.id,
|
||||
yardId: warehouseYard!.id,
|
||||
zoneId: warehouseZone!.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: 'DISPATCHED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
|
||||
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
|
||||
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
|
||||
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
|
||||
dispatchedAt: departure,
|
||||
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
seeded += 1;
|
||||
}
|
||||
|
||||
this.logger.log(`Export Djibouti interchange demo seed complete: ${seeded} train(s) seeded, ${skipped} skipped`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`ExportDjiboutiInterchangeDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user