From d5e8abcf6238fff908d94fb736eb2672e13f9b20 Mon Sep 17 00:00:00 2001 From: hagiye Date: Fri, 26 Jun 2026 23:37:09 +0300 Subject: [PATCH] get clearance fix --- .../src/contracts/contract-pdf.service.ts | 90 ++++++++- .../train-scheduling.service.ts | 8 +- .../warehouses/scheduling-read.facade.ts | 4 +- .../warehouses/warehouse-inventory.service.ts | 103 +++++++--- .../warehouse-invoice.controller.ts | 23 ++- .../warehouses/warehouse-invoice.service.ts | 164 ++++++++++++++++ .../warehouse-release-document.service.ts | 181 ++++++++++++++++++ .../modules/warehouses/warehouses.module.ts | 4 +- .../components/warehouses/FeePreviewModal.tsx | 26 ++- .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/constants/apiConfig.ts | 5 +- .../warehouses/WarehouseInvoicesPage.tsx | 106 +++++++++- .../src/services/warehouse.service.ts | 8 + 13 files changed, 669 insertions(+), 55 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts 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/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/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 2a61d9d78..ec39cfb39 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 @@ -133,7 +133,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`, ); } @@ -281,7 +281,7 @@ 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'], ); 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..c167e3077 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,7 +6,6 @@ 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 { DeliverInventoryDto } from './dto/deliver-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; @@ -283,7 +292,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 +303,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, @@ -892,6 +936,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', @@ -1688,14 +1733,8 @@ 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.quantity, inv.weight, @@ -1706,7 +1745,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 +1759,27 @@ 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 reference = `REL-${id.slice(0, 8).toUpperCase()}`; + const bookingReference = row?.bookingReference || row?.bookingId || 'N/A'; + const issuedAt = new Date(row.releaseDate); const html = this.buildReleaseDocumentHtml({ reference, issuedAt, @@ -1747,17 +1790,17 @@ 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, }); 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 +1885,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})`, ); 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..7ec13602d 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; @@ -34,6 +35,7 @@ export class WarehouseInvoiceService { private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly feeService: WarehouseFeeService, + private readonly documents: WarehouseReleaseDocumentService, ) {} // ── Generation ─────────────────────────────────────────────────────────── @@ -157,6 +159,27 @@ export class WarehouseInvoiceService { return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] }; } + async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE'); + 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 html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT'); + return { + filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, + buffer: await this.documents.htmlToPdfBuffer(html), + }; + } + listForInventory(inventoryId: string): Promise { return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); } @@ -213,4 +236,145 @@ 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 buildInvoiceDocumentHtml( + invoice: WarehouseFeeInvoice & { items: unknown[] }, + kind: 'INVOICE' | 'RECEIPT', + ): 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 ID${esc(invoice.bookingId)}
+
Inventory ID${esc(invoice.inventoryId)}
+
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..de9fac6e6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -0,0 +1,181 @@ +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, 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 : ['Warehouse release document']; + } + + private escapePdfText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + } +} 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-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/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 22236d3d9..3c0fd6bf8 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -358,6 +358,8 @@ export const URL_CONSTANTS = { WAREHOUSE_INVOICES: { BASE: '/warehouse-fee-invoices', BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`, + DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`, + RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`, CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`, PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`, GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`, diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index f2ca55c15..c342b6074 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,3 +1,2 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; - -//export const API_BASE_URL = 'http://localhost:3001'; \ No newline at end of file +export const API_BASE_URL = + import.meta.env.VITE_API_URL?.replace(/\/+$/, '') || 'https://edrfreightapi.triaplc.com'; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 0407447d4..6b93abc70 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -15,19 +15,21 @@ import { Text, TextInput, } from '@mantine/core'; -import { Ban, CreditCard, Eye, Search } from 'lucide-react'; +import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; +import { warehouseService } from '@/services/warehouse.service'; import { useToast } from '@/hooks/use-toast'; import { WAREHOUSE_INVOICE_STATUSES, type WarehouseFeeInvoice, type WarehouseInvoiceStatus, } from '@/types/warehouse'; +import { openPdfBlob } from '@/components/warehouses/pdf'; const STATUS_COLOR: Record = { DRAFT: 'gray', @@ -158,16 +160,86 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => ); const pay = useMutation(api.warehouses.payInvoice.mutationOptions()); const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions()); + const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions()); const [payAmount, setPayAmount] = useState(''); const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); + const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); + + const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => { + try { + const response = await warehouseService.downloadInvoiceDocument(invoice.id); + openPdfBlob(response.data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); + } catch (e) { + toast({ variant: 'destructive', title: 'Invoice download failed', description: (e as Error)?.message }); + } + }; + + const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => { + try { + const response = await warehouseService.downloadInvoiceReceipt(invoice.id); + openPdfBlob(response.data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`); + } catch (e) { + toast({ variant: 'destructive', title: 'Receipt download failed', description: (e as Error)?.message }); + } + }; + + const handleGateClearance = async (invoice: WarehouseFeeInvoice) => { + if (!invoice.inventoryId) { + toast({ + variant: 'destructive', + title: 'Gate clearance failed', + description: 'This invoice is not linked to an inventory item.', + }); + return; + } + + const pdfWindow = window.open('', '_blank'); + try { + const releasedItem = await gateClear.mutateAsync(invoice.inventoryId); + let documentResponse: Awaited>; + try { + documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId); + } catch (documentError) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Exit paper failed', + description: (documentError as Error)?.message, + }); + return; + } + const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`; + const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); + toast({ + title: 'Gate clearance recorded', + description: opened + ? 'The exit paper opened in a browser tab.' + : 'The browser blocked the preview tab, so the exit paper was downloaded.', + }); + onClose(); + } catch (e) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Gate clearance failed', + description: (e as Error)?.message, + }); + } + }; const handlePay = async () => { if (!inv || !payAmount) return; try { - await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } }); - toast({ title: 'Payment recorded' }); + const paidInvoice = await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } }); setPayAmount(''); + if (paidInvoice.status === 'PAID') { + toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' }); + await downloadReceiptPdf(paidInvoice); + await handleGateClearance(paidInvoice); + } else { + toast({ title: 'Payment recorded' }); + } } catch (e) { toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message }); } @@ -247,6 +319,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => )} + + {Number(inv.paidAmount) > 0 && ( + + )} + {canGateClear && ( + + )} {inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (