get clearance fix

This commit is contained in:
hagiye
2026-06-26 23:37:09 +03:00
parent f86bdb1c36
commit d5e8abcf62
13 changed files with 669 additions and 55 deletions

View File

@@ -80,8 +80,15 @@ export class ContractPdfService {
this.logger.error(
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
@@ -113,4 +120,85 @@ export class ContractPdfService {
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
);
}
private htmlToBasicPdfBuffer(html: string): Buffer {
const text = this.htmlToPlainText(html);
const lines = this.wrapLines(text, 92).slice(0, 72);
const body = lines
.map((line, index) => {
const prefix = index === 0 ? '50 790 Td' : '0 -12 Td';
return `${prefix} (${this.escapePdfText(line)}) Tj`;
})
.join('\n');
const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`;
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, 'latin1'));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
pdf += '% fallback padding\n';
}
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += '0000000000 65535 f \n';
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, 'latin1');
}
private htmlToPlainText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n');
}
private wrapLines(text: string, width: number): string[] {
const wrapped: string[] = [];
for (const rawLine of text.split('\n')) {
const words = rawLine.split(' ');
let line = '';
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > width && line) {
wrapped.push(line);
line = word;
} else {
line = next;
}
}
if (line) wrapped.push(line);
}
return wrapped.length ? wrapped : ['Document'];
}
private escapePdfText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
}

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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<WarehouseInventory> {
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})`,
);

View File

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

View File

@@ -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<WarehouseFeeInvoice[]> {
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<void> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
);
}
if (invoices.some((inv) => inv.status === 'PAID')) return;
const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
if (payableAmount > 0) {
throw new BadRequestException(
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
);
}
}
private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoice & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT',
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const money = (amount: unknown, currency = invoice.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const items = invoice.items as Array<{
id?: string;
description?: string;
feeType?: string;
quantity?: number;
unitRate?: number;
amount?: number;
currency?: string;
chargeableDays?: number | null;
}>;
const lastPayment = [...(invoice.payments ?? [])].pop();
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(invoice.invoiceNumber)}</strong>
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
<div><span>Booking ID</span>${esc(invoice.bookingId)}</div>
<div><span>Inventory ID</span>${esc(invoice.inventoryId)}</div>
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Fee type</th>
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${items
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
</tr>`,
)
.join('')}
</tbody>
</table>
<div class="totals">
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
</div>
<div class="footer">
<div class="line">Prepared by EDR warehouse finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
private safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
}

View File

@@ -0,0 +1,181 @@
import { existsSync } from 'fs';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
const MIN_VALID_PDF_BYTES = 2_000;
const RELEASE_DOCUMENT_PRINT_STYLES = `
<style id="warehouse-release-document-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
@Injectable()
export class WarehouseReleaseDocumentService {
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
}
this.logger.log(
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('warehouse-release-document-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
}
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((path) => existsSync(path));
}
private isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
}
private htmlToBasicPdfBuffer(html: string): Buffer {
const text = this.htmlToPlainText(html);
const lines = this.wrapLines(text, 92).slice(0, 72);
const body = lines
.map((line, index) => {
const prefix = index === 0 ? '50 790 Td' : '0 -12 Td';
return `${prefix} (${this.escapePdfText(line)}) Tj`;
})
.join('\n');
const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`;
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, 'latin1'));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
pdf += '% fallback padding\n';
}
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += '0000000000 65535 f \n';
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, 'latin1');
}
private htmlToPlainText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
.join('\n');
}
private wrapLines(text: string, width: number): string[] {
const wrapped: string[] = [];
for (const rawLine of text.split('\n')) {
const words = rawLine.split(' ');
let line = '';
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > width && line) {
wrapped.push(line);
line = word;
} else {
line = next;
}
}
if (line) wrapped.push(line);
}
return wrapped.length ? wrapped : ['Warehouse release document'];
}
private escapePdfText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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`,

View File

@@ -1,3 +1,2 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001';
export const API_BASE_URL =
import.meta.env.VITE_API_URL?.replace(/\/+$/, '') || 'https://edrfreightapi.triaplc.com';

View File

@@ -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<WarehouseInvoiceStatus, string> = {
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<number | ''>('');
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<ReturnType<typeof warehouseService.downloadReleaseDocument>>;
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: () =>
)}
<Group justify="flex-end" mt="sm">
<Button
variant="light"
color="gray"
leftSection={<Download size={16} />}
onClick={() => downloadInvoicePdf(inv)}
>
Invoice PDF
</Button>
{Number(inv.paidAmount) > 0 && (
<Button
variant="light"
color="teal"
leftSection={<Receipt size={16} />}
onClick={() => downloadReceiptPdf(inv)}
>
Receipt PDF
</Button>
)}
{canGateClear && (
<Button
color="edr-green"
leftSection={<DoorOpen size={16} />}
loading={gateClear.isPending}
onClick={() => handleGateClearance(inv)}
>
Gate clearance & exit paper
</Button>
)}
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice

View File

@@ -263,6 +263,14 @@ export const warehouseService = {
}),
getInvoice: (id: string) =>
apiClient.get<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.BY_ID(id)),
downloadInvoiceDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVOICES.DOCUMENT(id), {
responseType: 'blob',
}),
downloadInvoiceReceipt: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVOICES.RECEIPT(id), {
responseType: 'blob',
}),
invoicesForInventory: (inventoryId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
invoicesForBooking: (bookingId: string) =>