mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 13:53:47 +00:00
feat: add pdf to the central invoice system
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
} from '../billing/documents/invoice-document.service';
|
||||
import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util';
|
||||
import { applySettlement } from '../billing/invoice-settlement.util';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import {
|
||||
WarehouseFeeInvoice,
|
||||
@@ -11,7 +17,6 @@ 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;
|
||||
@@ -56,7 +61,7 @@ export class WarehouseInvoiceService {
|
||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly documents: WarehouseReleaseDocumentService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
@@ -161,18 +166,12 @@ export class WarehouseInvoiceService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
||||
private async nextInvoiceNumber(): Promise<string> {
|
||||
const now = new Date();
|
||||
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
const prefix = `WHF-${ymd}-`;
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
|
||||
[`${prefix}%`],
|
||||
);
|
||||
const next = Number(row?.seq ?? 0) + 1;
|
||||
return `${prefix}${String(next).padStart(5, '0')}`;
|
||||
/** WHF-YYYYMMDD-00001 — sequential per day (shared billing numbering). */
|
||||
private nextInvoiceNumber(): Promise<string> {
|
||||
return nextDailyInvoiceNumber(this.dataSource, {
|
||||
table: 'freight.warehouse_fee_invoices',
|
||||
code: 'WHF',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────
|
||||
@@ -186,12 +185,7 @@ export class WarehouseInvoiceService {
|
||||
|
||||
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),
|
||||
};
|
||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
|
||||
}
|
||||
|
||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
@@ -199,11 +193,69 @@ export class WarehouseInvoiceService {
|
||||
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 this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
|
||||
}
|
||||
|
||||
/** Map a warehouse fee invoice (with display details + items) onto the shared document model. */
|
||||
private toDocumentModel(
|
||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
): InvoiceDocumentModel {
|
||||
const items = invoice.items as Array<{
|
||||
description?: string;
|
||||
feeType?: string;
|
||||
quantity?: number;
|
||||
unitRate?: number;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
chargeableDays?: number | null;
|
||||
}>;
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
|
||||
|
||||
return {
|
||||
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
||||
kind,
|
||||
title: 'Warehouse Fee',
|
||||
documentNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt ?? invoice.createdAt,
|
||||
status: invoice.status,
|
||||
currency: invoice.currency,
|
||||
summary: [
|
||||
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
|
||||
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
|
||||
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
|
||||
{ label: 'Customer', value: invoice.customerName ?? null },
|
||||
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
|
||||
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
|
||||
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
|
||||
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
|
||||
{
|
||||
label: 'Yard / Zone',
|
||||
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
|
||||
},
|
||||
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
|
||||
{
|
||||
label: 'Payment',
|
||||
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
|
||||
},
|
||||
],
|
||||
categoryHeader: 'Fee type',
|
||||
lines: items.map((item) => ({
|
||||
description: item.description ?? null,
|
||||
category: item.feeType ?? null,
|
||||
quantity: item.quantity ?? item.chargeableDays ?? 0,
|
||||
unitRate: item.unitRate,
|
||||
amount: item.amount,
|
||||
currency: item.currency ?? invoice.currency,
|
||||
})),
|
||||
totals: [
|
||||
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
|
||||
{ label: 'Tax', amount: Number(invoice.taxAmount) },
|
||||
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
|
||||
{ label: 'Paid', amount: Number(invoice.paidAmount) },
|
||||
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,10 +289,11 @@ export class WarehouseInvoiceService {
|
||||
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
|
||||
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
|
||||
|
||||
const paidAmount = Number(invoice.paidAmount) + dto.amount;
|
||||
const total = Number(invoice.totalAmount);
|
||||
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
|
||||
const fullyPaid = paidAmount >= total;
|
||||
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
|
||||
invoice.totalAmount,
|
||||
invoice.paidAmount,
|
||||
dto.amount,
|
||||
);
|
||||
|
||||
const payments = [
|
||||
...(invoice.payments ?? []),
|
||||
@@ -248,8 +301,8 @@ export class WarehouseInvoiceService {
|
||||
];
|
||||
|
||||
const updated = await this.invoiceRepository.update(id, {
|
||||
paidAmount: Math.round(paidAmount * 100) / 100,
|
||||
balanceAmount: balance,
|
||||
paidAmount,
|
||||
balanceAmount,
|
||||
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
|
||||
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||
payments,
|
||||
@@ -460,131 +513,4 @@ export class WarehouseInvoiceService {
|
||||
|
||||
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
||||
}
|
||||
|
||||
private buildInvoiceDocumentHtml(
|
||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
details: InvoiceDocumentDetails,
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const money = (amount: unknown, currency = invoice.currency) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const items = invoice.items as Array<{
|
||||
id?: string;
|
||||
description?: string;
|
||||
feeType?: string;
|
||||
quantity?: number;
|
||||
unitRate?: number;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
chargeableDays?: number | null;
|
||||
}>;
|
||||
const lastPayment = [...(invoice.payments ?? [])].pop();
|
||||
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
|
||||
.doc { padding: 16px 8px; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
|
||||
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
|
||||
h1 { margin: 8px 0 0; font-size: 30px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; }
|
||||
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
|
||||
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
|
||||
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
|
||||
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
|
||||
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
|
||||
th { text-align: left; background: #f8fafc; color: #475569; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
|
||||
td.num, th.num { text-align: right; }
|
||||
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
|
||||
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
|
||||
.grand { font-size: 16px; font-weight: 800; }
|
||||
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Document no.
|
||||
<strong>${esc(invoice.invoiceNumber)}</strong>
|
||||
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal">${esc(sealText)}</div>
|
||||
<div class="summary">
|
||||
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
|
||||
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
|
||||
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
|
||||
<div><span>Customer</span>${esc(details.customerName)}</div>
|
||||
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
|
||||
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
|
||||
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
|
||||
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
|
||||
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
|
||||
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
|
||||
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Fee type</th>
|
||||
<th class="num">Qty</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${esc(item.description)}</td>
|
||||
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
|
||||
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
|
||||
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
|
||||
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
|
||||
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
|
||||
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
|
||||
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
|
||||
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="line">Prepared by EDR warehouse finance</div>
|
||||
<div class="line">Authorized seal / signature</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private safeFilename(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,23 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
|
||||
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);
|
||||
constructor(private readonly pdf: PdfRenderService) {}
|
||||
|
||||
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-';
|
||||
/**
|
||||
* Render the gate-clearance release document to PDF via the shared renderer,
|
||||
* falling back to the release-specific hand-built layout when Chromium is
|
||||
* unavailable.
|
||||
*/
|
||||
htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||
return this.pdf.htmlToPdfBuffer(html, {
|
||||
label: 'Warehouse release',
|
||||
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
|
||||
});
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
@@ -70,6 +71,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseFeeInvoiceItem,
|
||||
]),
|
||||
DocumentsModule,
|
||||
FilesModule,
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
|
||||
Reference in New Issue
Block a user