fix(bookings): freight order renders styled on Chromium-less servers

The customer-truck freight order went through ContractPdfService, whose only
fallback is a crude text dump - so on servers without Chromium the document
rendered as unstyled text.

- render via the shared PdfRenderService with the styled tabular fallback
  (same path as marshalling / release documents)
- rebuild the freight-order HTML in the standard document shape: brand header,
  booking summary tiles, one trucks table (plate / driver / type / containers /
  arrival), gate notice, signature lines - which is also what the fallback
  parser reads
- keep the two watermarked copies (Port Operations, Gate Security & Carrier)
  for the Chromium path; drop the now-unused ContractPdfService injection

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-10 07:04:41 +00:00
parent a7b429544b
commit 140c5a6469
2 changed files with 84 additions and 59 deletions

View File

@@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { DocumentsModule } from '../billing/documents/documents.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { BookingContractService } from './booking-contract.service';
@@ -68,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckContainer,
]),
BillingModule,
DocumentsModule,
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule),

View File

@@ -50,7 +50,8 @@ import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
@@ -99,7 +100,7 @@ export class BookingsService {
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
private readonly vehiclesService: VehiclesService,
private readonly contractPdfService: ContractPdfService,
private readonly pdfRender: PdfRenderService,
private readonly events: EventEmitter2,
) {}
@@ -170,7 +171,12 @@ export class BookingsService {
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
// Chromium when available; otherwise the styled tabular fallback (never the
// generic text dump — the freight order is an outward-facing gate document).
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
label: 'freight order',
fallback: (prepared) => buildTabularFallbackPdf(prepared),
});
return {
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
@@ -262,21 +268,10 @@ export class BookingsService {
containers: string | null;
}>,
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const assignedAt = booking.customerTruckAssignedAt
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
: '-';
const bookingRows: Array<[string, string | null | undefined]> = [
['Booking Reference', booking.reference],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['Freight Type', booking.freightType],
['Assigned At', assignedAt],
['Booking Status', booking.status],
];
const bookingRowHtml = bookingRows
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.join('');
// Fall back to the legacy single-truck booking columns when there are no
// multi-truck rows (bookings assigned before the multi-truck feature).
@@ -297,44 +292,63 @@ export class BookingsService {
]
: [];
const truckBlocks = truckList
.map((t, i) => {
const rows: Array<[string, string | null | undefined]> = [
['Truck Plate Number', t.plateNumber],
['Driver Name', t.driverName],
['Truck Type', t.truckType],
['Containers Loaded', t.containers],
[
'Arrival',
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
],
];
const html = rows
.map(
([label, value]) =>
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
)
.join('');
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
})
const truckRows = truckList
.map(
(t, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(t.plateNumber)}</td>
<td>${esc(t.driverName)}</td>
<td>${esc(t.truckType)}</td>
<td>${esc(t.containers)}</td>
<td>${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'}</td>
</tr>`,
)
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div class="watermark">${esc(watermark)}</div>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Freight Order</h1>
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
<div class="subtitle">Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</div>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${bookingRowHtml}</table>
${truckBlocks}
<div class="meta">
Booking
<strong>${esc(booking.reference)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Client</span><strong>${esc(booking.company?.name)}</strong></div>
<div class="tile"><span>Client ID</span><strong>${esc(booking.companyId)}</strong></div>
<div class="tile"><span>Trade direction</span><strong>${esc(booking.tradeDirection)}</strong></div>
<div class="tile"><span>Freight type</span><strong>${esc(booking.freightType)}</strong></div>
<div class="tile"><span>Assigned at</span><strong>${esc(assignedAt)}</strong></div>
<div class="tile"><span>Booking status</span><strong>${esc(booking.status)}</strong></div>
</div>
<table>
<thead>
<tr>
<th class="num">#</th>
<th>Truck plate</th>
<th>Driver</th>
<th>Truck type</th>
<th>Containers loaded</th>
<th>Arrival</th>
</tr>
</thead>
<tbody>${truckRows}</tbody>
</table>
<div class="notice">
Present this freight order at the warehouse gate. Each truck may only collect the
containers listed against it; the handover must be signed before any truck leaves.
</div>
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
<div>Gate Security Verification</div>
<div class="line">Customer / Carrier signature — date</div>
<div class="line">Port operations verification — date</div>
<div class="line">Gate security verification — date</div>
</div>
</section>`;
@@ -342,21 +356,30 @@ export class BookingsService {
<html>
<head>
<meta charset="utf-8" />
<title>Freight Order</title>
<style>
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.truck { page-break-inside: avoid; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
@page { size: A4 portrait; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.copy { position: relative; padding: 24px 28px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; color: rgba(15, 23, 42, 0.07); transform: rotate(-18deg); pointer-events: none; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.subtitle { margin-top: 4px; color: #64748b; font-size: 12px; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin: 4px 0; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 48px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 6px 7px; font-size: 10.5px; vertical-align: top; }
.num { text-align: right; width: 26px; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 30px; position: relative; z-index: 1; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 30px; }
</style>
</head>
<body>