diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 0f8595ca1..763232233 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -17,6 +17,7 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh" diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts index 9e0acc6fb..db2f7693e 100644 --- a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -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(//gi, '') + .replace(//gi, '') + .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') + .replace(//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, '\\)'); + } } diff --git a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts index b921a7194..0f9021ff2 100644 --- a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts +++ b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts new file mode 100644 index 000000000..523772b39 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts @@ -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 { + 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 { + 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'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index ba06604ce..1ac78355a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -45,7 +45,9 @@ export class FirstMileService { * unknown or the booking has not reached PAID status. */ async acceptBooking(bookingId: string): Promise { - const booking = await this.bookingsRepository.findById(bookingId); + const booking = await this.bookingsRepository.findById(bookingId, { + relations: { serviceType: true }, + }); if (!booking) { return null; @@ -55,6 +57,10 @@ export class FirstMileService { return null; } + if (!this.bookingRequestsFirstMile(booking)) { + return null; + } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -62,7 +68,11 @@ export class FirstMileService { } async acceptBookingByReference(bookingReference: string): Promise { - const booking = await this.bookingsRepository.findByReference(bookingReference); + const [booking] = await this.bookingsRepository.findAll({ + where: { reference: bookingReference }, + relations: { serviceType: true }, + take: 1, + }); if (!booking) { return null; @@ -72,6 +82,10 @@ export class FirstMileService { return null; } + if (!this.bookingRequestsFirstMile(booking)) { + return null; + } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -131,6 +145,11 @@ export class FirstMileService { } async create(dto: CreateFirstMileDto): Promise { + 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 +161,25 @@ export class FirstMileService { }); } + private async findByBookingId(bookingId: string): Promise { + 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: { + firstMilePickupAddress?: string | null; + serviceType?: { includesFirstMile?: boolean | null } | null; + }): boolean { + return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile); + } + async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 22b96a344..2e8fb2463 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 582e7467e..f4820704f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -344,10 +344,10 @@ export class PaymentService { ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } : { paymentStatus: "PAID", status: "PAID" }, ); - await this.firstMileService.acceptBooking(input.bookingId); - }); + await this.firstMileService.acceptBooking(input.bookingId); + if (isGeneralContract) { this.logger.log( `General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 256e6bcdd..c56dd6dd1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -724,9 +724,7 @@ export class TrainSchedulingService { } }); - const detail = await this.getTrainScheduleById(scheduleId); - const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); - return Object.assign(detail, { warehouseAutomation }); + return this.getTrainScheduleById(scheduleId); } async finalizeSchedule(scheduleId: string) { @@ -1136,7 +1134,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() { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 9bb734512..2feadaccd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,5 +1,154 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } 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; + + @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 +174,9 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + @ApiProperty({ type: TruckEntranceDto }) + truckEntrance!: TruckEntranceDto; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts index 46fecb044..e8a73190c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { TruckEntranceDto } from './bulk-receive.dto'; export class ReceiveWarehouseInventoryDto { @ApiProperty({ format: 'uuid' }) @@ -55,6 +56,9 @@ export class ReceiveWarehouseInventoryDto { @IsString() notes?: string; + @ApiProperty({ type: TruckEntranceDto }) + truckEntrance!: TruckEntranceDto; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 2a61d9d78..7d73dc6ef 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8171983df..ab3adb64a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -6,9 +6,8 @@ import { Cargo } from '../cargoes/entities/cargoes.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; -import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; @@ -36,10 +35,20 @@ import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; +import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; +const normalizeWagonStatus = (status: string | null | undefined) => + (status ?? '') + .trim() + .replace(/[\s-]+/g, '_') + .toUpperCase(); + +const isLoadableWagonStatus = (status: string | null | undefined) => + LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status)); + export interface InventoryInquiryResult { id: string; inventoryId: string | null; @@ -190,12 +199,18 @@ export interface EligibleBookingRow { weight: string | null; paymentStatus: string; status: string; + hasFirstMile: boolean; + firstMileRequestId: string | null; + firstMileStatus: string | null; + firstMileVehicleId: string | null; + firstMileTruckPlateNumber: string | null; + firstMileTrailerPlateNumber: string | null; } export interface BulkReceiveResult { receivedCount: number; skippedCount: number; - results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[]; + results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; } export interface LoadPassedExportResult { @@ -283,7 +298,7 @@ export class WarehouseInventoryService { private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, - private readonly pdfService: ContractPdfService, + private readonly releaseDocuments: WarehouseReleaseDocumentService, private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, ) {} @@ -294,18 +309,53 @@ export class WarehouseInventoryService { * inspection / storage / loading steps — only the final release. */ async gateClearance(id: string, performedBy?: string): Promise { - const item = await this.findById(id); + const [item]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" + FROM freight.warehouse_inventory + WHERE id = $1 AND deleted_at IS NULL + LIMIT 1`, + [id], + ); + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + const blocking = await this.invoices.findBlockingInvoice(id); if (blocking) { throw new BadRequestException( 'Warehouse demurrage/storage fee must be paid before terminal release.', ); } + const now = new Date(); - await this.inventoryRepository.update(id, { - gateClearedAt: now, - releaseDate: item.releaseDate ?? now, - }); + const [gateColumn]: Array<{ exists: boolean }> = await this.dataSource.query( + `SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'warehouse_inventory' + AND column_name = 'gate_cleared_at' + ) AS "exists"`, + ); + if (gateColumn?.exists) { + await this.dataSource.query( + `UPDATE freight.warehouse_inventory + SET gate_cleared_at = $2, + release_date = COALESCE(release_date, $2), + updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + [id, now], + ); + } else { + await this.dataSource.query( + `UPDATE freight.warehouse_inventory + SET release_date = COALESCE(release_date, $2), + updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + [id, now], + ); + } + await this.activityLog.record({ activityType: 'INVENTORY_DISPATCHED', inventoryId: id, @@ -602,13 +652,29 @@ export class WarehouseInventoryService { COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", b.cargo_total_weight_vgm AS "weight", b.payment_status AS "paymentStatus", - b.status AS "status" + b.status AS "status", + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus", + fm.vehicle_id AS "firstMileVehicleId", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' AND inv.id IS NULL @@ -629,6 +695,7 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + this.assertTruckEntrance(dto.truckEntrance); await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -645,10 +712,22 @@ export class WarehouseInventoryService { const [booking] = await manager.query( `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", - oy.country AS "originCountry", dy.country AS "destinationCountry" + oy.country AS "originCountry", dy.country AS "destinationCountry", + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus" FROM freight.bookings b LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); @@ -663,10 +742,28 @@ export class WarehouseInventoryService { skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); continue; } + if (dto.direction === 'EXPORT' && booking.hasFirstMile) { + if (!booking.firstMileRequestId) { + skip('First-mile request not created'); + continue; + } + if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { + skip('First-mile truck has not arrived'); + continue; + } + } const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); if (existing) { skip('Already received'); continue; } + const now = new Date(); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const receiveNote = this.buildReceiveNote({ + grnNumber, + direction: dto.direction, + notes: `Bulk received (${dto.direction})`, + truckEntrance: dto.truckEntrance, + }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -676,8 +773,8 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'RECEIVED', - arrivedAt: new Date(), - notes: `Bulk received (${dto.direction})`, + arrivedAt: now, + notes: receiveNote, }), ); @@ -686,14 +783,14 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `Bulk received ${dto.direction} booking`, + description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, ); result.receivedCount += 1; - result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id }); + result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); } }); @@ -892,6 +989,7 @@ export class WarehouseInventoryService { /** Booking statuses that must never be unloaded into warehouse inventory. */ private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED']; private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [ + 'LOADED', 'DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', @@ -1263,16 +1361,22 @@ export class WarehouseInventoryService { }); if (result.unloadedCount > 0) { - const document = await this.interchangeDocuments.generateFromSchedule({ + let document = await this.interchangeDocuments.generateFromSchedule({ scheduleId, direction: 'EXPORT', handoverLocation: schedule.destinationName ?? 'Djibouti Port', handoverFrom: 'EDR', handoverTo: 'Djibouti Port Operator', portOperatorName: 'Doraleh Multipurpose Port', - generatedBy: performedBy, - remarks: 'Generated after export unloading at Djibouti Port', + generatedBy: performedBy ?? 'EDR Operations', + remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', }); + if (document.status !== 'ACKNOWLEDGED') { + document = await this.interchangeDocuments.acknowledge(document.id, { + acknowledgedBy: 'Djibouti Port Operator', + remarks: 'Auto acknowledged after Djibouti export unloading.', + }); + } result.interchangeDocument = { id: document.id, documentNo: document.documentNo, @@ -1379,9 +1483,11 @@ export class WarehouseInventoryService { } async receive(dto: ReceiveWarehouseInventoryDto): Promise { + this.assertTruckEntrance(dto.truckEntrance); const weight = Number(dto.weight) || 0; const volume = Number(dto.volume) || 0; const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; + const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); @@ -1395,6 +1501,12 @@ export class WarehouseInventoryService { this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); + const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const receiveNote = this.buildReceiveNote({ + grnNumber, + notes: dto.notes?.trim() || 'Single booking received', + truckEntrance: dto.truckEntrance, + }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -1409,7 +1521,7 @@ export class WarehouseInventoryService { volume: dto.volume ?? null, status: 'RECEIVED', arrivedAt: now, - notes: dto.notes?.trim() ?? null, + notes: receiveNote, }), ); @@ -1420,7 +1532,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `Received ${weight}kg at warehouse location`, + description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, @@ -1688,15 +1800,10 @@ export class WarehouseInventoryService { } async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { - const item = await this.findById(id); - if (!item.releaseDate) { - throw new BadRequestException('A release order must be issued before downloading the exit paper'); - } - const [row] = await this.dataSource.query( `SELECT inv.id, - inv.release_order_reference AS "releaseOrderReference", inv.release_date AS "releaseDate", + inv.release_order_reference AS "releaseOrderReference", inv.quantity, inv.weight, inv.status, @@ -1706,7 +1813,7 @@ export class WarehouseInventoryService { b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", company.name AS "customerName", - container.container_number AS "containerNumber", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -1720,23 +1827,29 @@ export class WarehouseInventoryService { LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id - LEFT JOIN freight.containers container ON ( - (inv.container_id IS NOT NULL AND container.id = inv.container_id) - OR (inv.container_id IS NULL AND container.booking_id = b.id) - ) AND container.deleted_at IS NULL - LEFT JOIN freight.cargoes cargo ON ( - (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) - OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) - ) AND cargo.deleted_at IS NULL + 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) WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [id], ); + if (!row) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + if (!row.releaseDate) { + throw new BadRequestException('A release order must be issued before downloading the exit paper'); + } - const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`; - const bookingReference = row?.bookingReference || item.bookingId || 'N/A'; - const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date(); + const bookingReference = row?.bookingReference || 'N/A'; + const reference = + row?.releaseOrderReference || + (row?.bookingReference ? `REL-${String(row.bookingReference).replace(/^BK-?/i, '')}` : 'REL-N/A'); + const issuedAt = new Date(row.releaseDate); const html = this.buildReleaseDocumentHtml({ reference, issuedAt, @@ -1747,17 +1860,18 @@ export class WarehouseInventoryService { tradeDirection: row?.tradeDirection ?? null, containerNumber: row?.containerNumber ?? null, cargoDescription: row?.cargoDescription ?? null, - quantity: Number(row?.quantity ?? item.quantity ?? 0), - weight: Number(row?.weight ?? item.weight ?? 0), + quantity: Number(row?.quantity ?? 0), + weight: Number(row?.weight ?? 0), warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null, yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null, zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, - inventoryStatus: row?.status ?? item.status, + inventoryStatus: row?.status ?? null, + clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', }); return { filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.pdfService.htmlToPdfBuffer(html), + buffer: await this.releaseDocuments.htmlToPdfBuffer(html), }; } @@ -1842,7 +1956,7 @@ export class WarehouseInventoryService { // 4. wagon must be available, or already selected by an existing train schedule. const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId); - if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) { + if (!isLoadableWagonStatus(wagon.status) && !scheduled) { throw new BadRequestException( `Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`, ); @@ -2257,6 +2371,7 @@ export class WarehouseInventoryService { yard: string | null; zone: string | null; inventoryStatus: string | null; + clearanceStatus: string; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -2273,71 +2388,85 @@ export class WarehouseInventoryService { minute: '2-digit', }); const rows = [ - ['Booking reference', data.bookingReference], - ['Customer', data.customerName], - ['Booking status', data.bookingStatus], - ['Freight type', data.freightType], - ['Trade direction', data.tradeDirection], - ['Container number', data.containerNumber], - ['Cargo / goods', data.cargoDescription], + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName], + ['Booking Status', data.bookingStatus], + ['Freight Type', data.freightType], + ['Trade Direction', data.tradeDirection], + ['Container Number', data.containerNumber], + ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Weight', `${data.weight.toLocaleString()} kg`], + ['Declared Weight', `${data.weight.toLocaleString()} kg`], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], - ['Inventory status', data.inventoryStatus], + ['Inventory Status', data.inventoryStatus], + ['Clearance Status', data.clearanceStatus], ]; return ` - Warehouse Release Exit Paper + Warehouse Gate Clearance / Release Order
-
EDR Warehouse Operations
-

Warehouse Release / Exit Paper

+
Ethio-Djibouti Railway S.C.
+

Warehouse Gate Clearance / Release Order

+
Official warehouse release and exit authorization
- Release reference + Document / Release No. ${esc(data.reference)} Issued: ${esc(issuedAt)}
+
- This document authorizes the listed booking/goods to leave the warehouse after release checks. + 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.
+
Release Particulars
${rows.map(([label, value]) => ``).join('')}
${esc(label)}${esc(value)}
-
-
Warehouse officer name / signature / date
-
Customer or driver name / signature / date
+
Authorization Clause
+
+ 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.
- @@ -2378,6 +2507,71 @@ export class WarehouseInventoryService { return trimmed ? `${trimmed}\n${note}` : note; } + private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void { + if (!truckEntrance?.truckPlateNumber?.trim()) { + throw new BadRequestException('Truck plate number is required for entrance registration'); + } + if (!truckEntrance.driverName?.trim()) { + throw new BadRequestException('Driver name is required for entrance registration'); + } + if (!truckEntrance.driverPhone?.trim()) { + throw new BadRequestException('Driver phone is required for entrance registration'); + } + if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) { + throw new BadRequestException('Entrance tare weight is required for entrance registration'); + } + } + + private generateGrnNumber(direction: string, referenceId: string, date: Date): string { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + } + + private buildReceiveNote(input: { + grnNumber: string; + direction?: string | null; + notes?: string | null; + truckEntrance: TruckEntranceDto; + }): string { + const truck = input.truckEntrance; + const rows = [ + `GRN Number: ${input.grnNumber}`, + input.direction ? `Direction: ${input.direction}` : null, + truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null, + truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, + truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, + truck.tin ? `TIN: ${truck.tin}` : null, + `Truck Plate: ${truck.truckPlateNumber}`, + truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, + truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, + truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null, + truck.truckType ? `Truck Type: ${truck.truckType}` : null, + `Driver: ${truck.driverName}`, + `Driver Phone: ${truck.driverPhone}`, + truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, + `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`, + truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, + truck.incoterms ? `Incoterms: ${truck.incoterms}` : null, + truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, + truck.itemCode ? `Item Code: ${truck.itemCode}` : null, + truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null, + truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null, + truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, + truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, + truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, + truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, + truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, + truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, + truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null, + truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null, + truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null, + input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null, + ]; + return rows.filter(Boolean).join('\n'); + } + private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise { const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index da818b5a3..a66cf91d0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 29493bc9a..45c471db1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -10,6 +10,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; @@ -27,6 +28,22 @@ export interface PayInvoiceDto { 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; + @Injectable() export class WarehouseInvoiceService { constructor( @@ -34,6 +51,7 @@ export class WarehouseInvoiceService { private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly feeService: WarehouseFeeService, + private readonly documents: WarehouseReleaseDocumentService, ) {} // ── Generation ─────────────────────────────────────────────────────────── @@ -150,11 +168,35 @@ export class WarehouseInvoiceService { } // ── Reads ──────────────────────────────────────────────────────────────── - async findById(id: string): Promise { + async findById(id: string): Promise { 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 { @@ -213,4 +255,207 @@ 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 { + 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 { + 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 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, '''); + 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 ` + + + + Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}

+
+
+ Document no. + ${esc(invoice.invoiceNumber)} + Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} +
+
+
${esc(sealText)}
+
+
Status${esc(invoice.status.replace(/_/g, ' '))}
+
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
+
Booking reference${esc(details.bookingReference)}
+
Customer${esc(details.customerName)}
+
Inventory reference${esc(details.inventoryReference)}
+
Inventory info${esc(details.inventoryInfo)}
+
Clearance${esc(details.clearanceStatus)}
+
Warehouse${esc(details.warehouseName)}
+
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
+
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
+
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
+
+ + + + + + + + + + + + ${items + .map( + (item) => ` + + + + + + `, + ) + .join('')} + +
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
+
+
Subtotal${esc(money(invoice.subtotalAmount))}
+
Tax${esc(money(invoice.taxAmount))}
+
Total${esc(money(invoice.totalAmount))}
+
Paid${esc(money(invoice.paidAmount))}
+
Balance${esc(money(invoice.balanceAmount))}
+
+ +
+ +`; + } + + private safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts new file mode 100644 index 000000000..4398d943e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -0,0 +1,240 @@ +import { existsSync } from 'fs'; + +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; + +const MIN_VALID_PDF_BYTES = 2_000; + +const RELEASE_DOCUMENT_PRINT_STYLES = ` +`; + +@Injectable() +export class WarehouseReleaseDocumentService { + private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + + async htmlToPdfBuffer(html: string): Promise { + 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('')) { + return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); + } + 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 text = this.htmlToPlainText(html); + const lines = this.wrapLines(text, 86).slice(0, 52); + const body = lines + .map((line, index) => { + const y = 770 - index * 12; + const isTitle = index < 2 || /clearance|release order/i.test(line); + const size = index === 0 ? 13 : isTitle ? 11 : 9.6; + const font = isTitle ? 'F2' : 'F1'; + return this.textOp(line, 48, y, size, font); + }) + .join('\n'); + const stream = [ + this.lineOp(48, 752, 548, 752), + body, + this.circularSealOps(184, 154), + this.lineOp(48, 92, 278, 92, '0 0 0'), + this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'), + this.lineOp(326, 92, 548, 92, '0 0 0'), + this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'), + this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'), + ].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 htmlToPlainText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') + .replace(//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'): string { + return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; + } + + private circularSealOps(cx: number, cy: number): string { + return [ + 'q', + '0.08 0.32 0.18 RG', + '0.08 0.32 0.18 rg', + '2.2 w', + this.circlePath(cx, cy, 51), + 'S', + '0.8 w', + this.circlePath(cx, cy, 41), + 'S', + this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'), + this.textOp('CLEARED', cx - 27, cy - 16, 11, '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'); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 2a77fef74..116bfabf1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,7 +3,6 @@ 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'; @@ -32,6 +31,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'; @@ -111,8 +111,8 @@ import { WarehousesService } from './warehouses.service'; WarehouseFeeService, WarehouseInvoiceService, WarehouseSchedulingAdapterService, + WarehouseReleaseDocumentService, SchedulingReadFacade, - ContractPdfService, ], exports: [ WarehousesService, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts new file mode 100644 index 000000000..24118d882 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -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); +}); diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 21a37668f..93caae0a4 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite --port 5183", + "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", "lint": "eslint src", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4e268c99f..ab1356a42 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -77,6 +77,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; +import { HealthCheck } from "./features/health/HealthCheck"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -374,6 +375,7 @@ const App = () => { return ( } /> + } /> } /> } /> }> diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 25712c742..5aa78e2d3 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -1,6 +1,6 @@ import axios from "axios"; -import { API_BASE_URL } from "@/pages/fleet/config/vehicles"; +import { API_BASE_URL } from "@/constants/apiConfig"; import { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx index ffaf471b3..ff37edb72 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx @@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Loader2 } from 'lucide-react'; -import { API_BASE_URL } from "@/pages/fleet/config/vehicles"; +import { API_BASE_URL } from "@/constants/apiConfig"; interface Cargo { id: string; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 6167feb07..efdca3ae4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -134,15 +134,23 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa const pdfWindow = window.open('', '_blank'); try { const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem; - const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId); - const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`; - const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); - toast({ - title: 'Gate clearance recorded', - description: opened - ? 'The release PDF opened in a browser tab.' - : 'The browser blocked the preview tab, so the PDF was downloaded.', - }); + try { + const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId); + const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`; + const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); + toast({ + title: 'Gate clearance recorded', + description: opened + ? 'The release PDF opened in a browser tab.' + : 'The browser blocked the preview tab, so the PDF was downloaded.', + }); + } catch (documentError) { + pdfWindow?.close(); + toast({ + title: 'Gate clearance recorded', + description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`, + }); + } onClose(); } catch (error) { pdfWindow?.close(); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx index c18f97acc..0dc04e450 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx @@ -69,6 +69,8 @@ export function InterchangeDocumentDetailPanel({ id }: { id: string }) { /> + + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx index ad079577b..b0b864e4d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx @@ -24,6 +24,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode }) } export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) { + const bookingReference = item?.booking?.reference ?? '-'; + const inventorySummary = [ + item?.status?.replace(/_/g, ' '), + item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null, + item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null, + ] + .filter(Boolean) + .join(' / '); + return ( {!item ? ( @@ -33,10 +42,10 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM - {item.booking?.reference ?? item.bookingId ?? item.id} + {bookingReference} - Inventory ID: {item.id} + {inventorySummary || 'Inventory information'} @@ -51,12 +60,12 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM + - - - + + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 3cd08461f..a2437fe8f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -18,16 +18,20 @@ import { } from '@mantine/core'; import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { firstMileService } from '@/services/first-mile.service'; import type { + EligibleBooking, ImportTrain, ImportTrainItem, ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, + TruckEntrancePayload, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { InspectionReportModal } from './InspectionReportModal'; @@ -49,6 +53,305 @@ interface Location { zoneId: string; } +interface TruckEntranceFormState { + ownerName: string; + consigneeDetails: string; + edrDigitalBookingId: string; + tin: string; + truckPlateNumber: string; + trailerPlateNumber: string; + assignedEquipmentNumber: string; + customsSealNumber: string; + declarationNumber: string; + incoterms: string; + hsCodes: string; + itemCode: string; + itemDescription: string; + packagingType: string; + unitCount: number | ''; + grossWeightKg: number | ''; + netWeightKg: number | ''; + volumeDimensions: string; + conditionAtReceipt: string; + damagedRejectedQuantity: number | ''; + warehouseCodeLocation: string; + driverName: string; + driverPhone: string; + driverLicenseNumber: string; + truckType: string; + entranceTareWeightKg: number | ''; + exitTareWeightKg: number | ''; + driverSignatoryName: string; + warehouseManagerName: string; +} + +const emptyTruckEntrance = (): TruckEntranceFormState => ({ + ownerName: '', + consigneeDetails: '', + edrDigitalBookingId: '', + tin: '', + truckPlateNumber: '', + trailerPlateNumber: '', + assignedEquipmentNumber: '', + customsSealNumber: '', + declarationNumber: '', + incoterms: '', + hsCodes: '', + itemCode: '', + itemDescription: '', + packagingType: '', + unitCount: '', + grossWeightKg: '', + netWeightKg: '', + volumeDimensions: '', + conditionAtReceipt: '', + damagedRejectedQuantity: '', + warehouseCodeLocation: '', + driverName: '', + driverPhone: '', + driverLicenseNumber: '', + truckType: '', + entranceTareWeightKg: '', + exitTareWeightKg: '', + driverSignatoryName: '', + warehouseManagerName: '', +}); + +const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ + ownerName: form.ownerName.trim() || undefined, + consigneeDetails: form.consigneeDetails.trim() || undefined, + edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, + tin: form.tin.trim() || undefined, + truckPlateNumber: form.truckPlateNumber.trim(), + trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, + assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, + customsSealNumber: form.customsSealNumber.trim() || undefined, + declarationNumber: form.declarationNumber.trim() || undefined, + incoterms: form.incoterms.trim() || undefined, + hsCodes: form.hsCodes.trim() || undefined, + itemCode: form.itemCode.trim() || undefined, + itemDescription: form.itemDescription.trim() || undefined, + packagingType: form.packagingType.trim() || undefined, + unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), + grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg), + netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg), + volumeDimensions: form.volumeDimensions.trim() || undefined, + conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, + damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity), + warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined, + driverName: form.driverName.trim(), + driverPhone: form.driverPhone.trim(), + driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, + truckType: form.truckType.trim() || undefined, + entranceTareWeightKg: Number(form.entranceTareWeightKg), + exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg), + driverSignatoryName: form.driverSignatoryName.trim() || undefined, + warehouseManagerName: form.warehouseManagerName.trim() || undefined, +}); + +function TruckEntranceFields({ + value, + onChange, +}: { + value: TruckEntranceFormState; + onChange: (next: TruckEntranceFormState) => void; +}) { + return ( + + Customer and cargo ownership + + onChange({ ...value, ownerName: e.currentTarget.value })} + /> + onChange({ ...value, consigneeDetails: e.currentTarget.value })} + /> + + + onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })} + /> + onChange({ ...value, tin: e.currentTarget.value })} + /> + + + Transport and equipment tracking + + onChange({ ...value, truckPlateNumber: e.currentTarget.value })} + /> + onChange({ ...value, trailerPlateNumber: e.currentTarget.value })} + /> + + + onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })} + /> + onChange({ ...value, customsSealNumber: e.currentTarget.value })} + /> + + + onChange({ ...value, driverName: e.currentTarget.value })} + /> + onChange({ ...value, driverPhone: e.currentTarget.value })} + /> + + + onChange({ ...value, driverLicenseNumber: e.currentTarget.value })} + /> + onChange({ ...value, truckType: e.currentTarget.value })} + /> + + + onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })} + /> + onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })} + /> + + + Customs and compliance + + onChange({ ...value, declarationNumber: e.currentTarget.value })} + /> + onChange({ ...value, incoterms: e.currentTarget.value })} + /> + + onChange({ ...value, hsCodes: e.currentTarget.value })} + /> + + Physical cargo specifications + + onChange({ ...value, itemCode: e.currentTarget.value })} + /> + onChange({ ...value, itemDescription: e.currentTarget.value })} + /> + + + onChange({ ...value, packagingType: e.currentTarget.value })} + /> + onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} + /> + + + onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })} + /> + onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })} + /> + + onChange({ ...value, volumeDimensions: e.currentTarget.value })} + /> + + Quality and inspection + + onChange({ ...value, conditionAtReceipt: e.currentTarget.value })} + /> + onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })} + /> + + onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })} + /> + + onChange({ ...value, driverSignatoryName: e.currentTarget.value })} + /> + onChange({ ...value, warehouseManagerName: e.currentTarget.value })} + /> + + + ); +} + /** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */ function LocationSelects({ value, @@ -140,20 +443,105 @@ function EligibleTab({ onChanged?: () => void; }) { const { toast } = useToast(); + const qc = useQueryClient(); const { data: allRows = [], isLoading } = useQuery( api.warehouses.eligibleBookings.queryOptions({ enabled }), ); const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]); const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions()); const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); + const requestFirstMile = useMutation({ + mutationFn: (reference: string) => firstMileService.accept(reference), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + toast({ title: 'First mile requested', description: 'Booking was added to the existing First Mile workflow.' }); + }, + onError: (error) => { + toast({ variant: 'destructive', title: 'First mile request failed', description: extractErrorMessage(error) }); + }, + }); const [selected, setSelected] = useState>(new Set()); + const [statusTab, setStatusTab] = useState('ALL'); + const [truckOpen, setTruckOpen] = useState(false); + const [pendingReceiveIds, setPendingReceiveIds] = useState([]); + const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); - const allSelected = rows.length > 0 && selected.size === rows.length; + const canReceiveBooking = (row: EligibleBooking) => + !(direction === 'EXPORT' && row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'); + const statusOptions = useMemo(() => { + const base = [{ value: 'ALL', label: 'All bookings' }]; + if (direction === 'EXPORT') { + return [ + ...base, + { value: 'DIRECT', label: 'Direct truck' }, + { value: 'FIRST_MILE', label: 'First mile' }, + { value: 'FIRST_MILE_READY', label: 'First mile arrived' }, + { value: 'AWAITING_FIRST_MILE', label: 'Awaiting first mile' }, + ]; + } + return [ + ...base, + { value: 'READY_TO_RECEIVE', label: 'Ready to receive' }, + { value: 'PAID', label: 'Paid' }, + ]; + }, [direction]); + const statusFilteredRows = useMemo( + () => + rows.filter((row) => { + switch (statusTab) { + case 'DIRECT': + return !row.hasFirstMile; + case 'FIRST_MILE': + return row.hasFirstMile; + case 'FIRST_MILE_READY': + return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT'; + case 'AWAITING_FIRST_MILE': + return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'; + case 'READY_TO_RECEIVE': + return canReceiveBooking(row); + case 'PAID': + return row.paymentStatus === 'PAID'; + default: + return true; + } + }), + [rows, statusTab], + ); + const statusCounts = useMemo( + () => + Object.fromEntries( + statusOptions.map((option) => [ + option.value, + rows.filter((row) => { + switch (option.value) { + case 'DIRECT': + return !row.hasFirstMile; + case 'FIRST_MILE': + return row.hasFirstMile; + case 'FIRST_MILE_READY': + return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT'; + case 'AWAITING_FIRST_MILE': + return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'; + case 'READY_TO_RECEIVE': + return canReceiveBooking(row); + case 'PAID': + return row.paymentStatus === 'PAID'; + default: + return true; + } + }).length, + ]), + ), + [rows, statusOptions], + ); + const selectableRows = statusFilteredRows.filter(canReceiveBooking); + const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length; const someSelected = selected.size > 0 && !allSelected; const toggleAll = () => - setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); + setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); const toggleOne = (id: string) => setSelected((prev) => { const next = new Set(prev); @@ -161,7 +549,7 @@ function EligibleTab({ return next; }); - const receive = async (bookingIds: string[]) => { + const openTruckReceive = (bookingIds: string[]) => { if (!locationReady) { toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' }); return; @@ -170,13 +558,41 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'Select at least one booking' }); return; } + const allowedIds = new Set(selectableRows.map((row) => row.id)); + const filteredIds = bookingIds.filter((id) => allowedIds.has(id)); + if (filteredIds.length === 0) { + toast({ variant: 'destructive', title: 'No selected booking is ready to receive' }); + return; + } + const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null; + setPendingReceiveIds(filteredIds); + setTruckForm({ + ...emptyTruckEntrance(), + truckPlateNumber: row?.firstMileTruckPlateNumber ?? '', + trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '', + }); + setTruckOpen(true); + }; + + const receive = async () => { + if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { + toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); + return; + } try { - const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds }); + const r = await bulkReceive.mutateAsync({ + direction, + ...location, + bookingIds: pendingReceiveIds, + truckEntrance: toTruckEntrancePayload(truckForm), + }); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), }); setSelected(new Set()); + setTruckOpen(false); + setPendingReceiveIds([]); onChanged?.(); } catch (error) { toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); @@ -198,9 +614,19 @@ function EligibleTab({ return ( + setStatusTab(v ?? 'ALL')}> + + {statusOptions.map((option) => ( + + {option.label} ({statusCounts[option.value] ?? 0}) + + ))} + + + - Selected: {selected.size} / {rows.length} eligible + Selected: {selected.size} / {statusFilteredRows.length} eligible {direction === 'EXPORT' && ( @@ -218,9 +644,9 @@ function EligibleTab({ @@ -228,7 +654,7 @@ function EligibleTab({ size="compact-sm" disabled={!locationReady || selected.size === 0} loading={bulkReceive.isPending} - onClick={() => receive([...selected])} + onClick={() => openTruckReceive([...selected])} > Receive Selected @@ -245,7 +671,7 @@ function EligibleTab({ - ) : rows.length === 0 ? ( + ) : statusFilteredRows.length === 0 ? ( No eligible PAID {direction.toLowerCase()} bookings to receive. @@ -275,16 +701,20 @@ function EligibleTab({ Payment Current Status Inspection + {direction === 'EXPORT' && First Mile} Actions - {rows.map((r) => ( + {statusFilteredRows.map((r) => { + const canReceive = canReceiveBooking(r); + return ( toggleOne(r.id)} /> @@ -319,23 +749,73 @@ function EligibleTab({ + {direction === 'EXPORT' && ( + + {r.hasFirstMile ? ( + + + {r.firstMileStatus ?? 'Request needed'} + + + {[r.firstMileTruckPlateNumber, r.firstMileTrailerPlateNumber].filter(Boolean).join(' / ') || 'Truck not assigned'} + + + ) : ( + Direct arrival + )} + + )} - + {direction === 'EXPORT' && r.hasFirstMile && !r.firstMileRequestId ? ( + + ) : ( + + )} - ))} + ); + })} )} + + setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg"> + + + Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. + + + + + + + + ); } @@ -687,6 +1167,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { Cargo Type Weight Arrival + Inspection Current Status Last Mile Pickup Option @@ -709,6 +1190,11 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { {it.cargoType ?? '—'} {formatNumber(Number(it.weight))} {formatDate(it.arrivalTime)} + + + {it.inspectionStatus ?? 'Not inspected'} + + {it.currentStatus ?? '—'} @@ -726,6 +1212,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { } /** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */ +const getPendingUnloadBookings = (train: ImportTrain) => + train.pendingUnloadBookings ?? train.totalBookings; + +const isFullyUnloaded = (train: ImportTrain) => + Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); + function ImportArriveQueueTab({ enabled, onChanged, @@ -744,9 +1236,19 @@ function ImportArriveQueueTab({ const [busyId, setBusyId] = useState(null); const autoUnload = async (train: ImportTrain) => { + if (isFullyUnloaded(train)) { + toast({ + title: 'Already unloaded', + description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`, + }); + return; + } + setBusyId(train.scheduleId); try { const r = await autoUnloadMutation.mutateAsync(train.scheduleId); + const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0; + const firstReason = r.results.find((item) => item.reason)?.reason; const extra = [ r.skippedCount ? `${r.skippedCount} skipped` : '', r.failedCount ? `${r.failedCount} failed` : '', @@ -754,8 +1256,8 @@ function ImportArriveQueueTab({ .filter(Boolean) .join(', '); toast({ - title: `${r.unloadedCount} unloaded`, - description: extra || undefined, + title: alreadyUnloaded ? 'Already unloaded' : `${r.unloadedCount} unloaded`, + description: alreadyUnloaded ? firstReason ?? 'This train is already in warehouse inventory.' : extra || undefined, }); onChanged?.(); } catch (error) { @@ -800,6 +1302,8 @@ function ImportArriveQueueTab({ {trains.map((t: ImportTrain) => { const isOpen = openId === t.scheduleId; + const fullyUnloaded = isFullyUnloaded(t); + const unloadedBookings = t.unloadedBookings ?? t.totalBookings - getPendingUnloadBookings(t); return ( @@ -817,7 +1321,14 @@ function ImportArriveQueueTab({ {t.totalContainers} {t.totalCargoes} - {t.status} + + + {fullyUnloaded ? 'UNLOADED' : t.status} + + + {Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded + + @@ -831,12 +1342,13 @@ function ImportArriveQueueTab({ @@ -1171,11 +1683,13 @@ function SingleBookingReceiveModal({ volume: '', notes: '', }); + const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); useEffect(() => { if (opened) { setSelectedBooking(bookingId ?? ''); setForm({ warehouseId: '', yardId: '', zoneId: '', quantity: '', weight: '', volume: '', notes: '' }); + setTruckForm(emptyTruckEntrance()); } }, [opened, bookingId]); @@ -1194,6 +1708,10 @@ function SingleBookingReceiveModal({ toast({ variant: 'destructive', title: 'Quantity and weight are required' }); return; } + if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { + toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); + return; + } const payload: ReceiveInventoryPayload = { bookingId: selectedBooking.trim(), warehouseId: form.warehouseId, @@ -1203,6 +1721,7 @@ function SingleBookingReceiveModal({ weight: Number(form.weight), volume: form.volume === '' ? undefined : Number(form.volume), notes: form.notes.trim() || undefined, + truckEntrance: toTruckEntrancePayload(truckForm), }; try { await receiveMutation.mutateAsync(payload); @@ -1249,6 +1768,8 @@ function SingleBookingReceiveModal({ /> + +