Invoice and clearance seal approval

This commit is contained in:
hagiye
2026-06-27 01:13:25 +03:00
parent 8005282f9e
commit b2b668a392
8 changed files with 265 additions and 63 deletions

View File

@@ -1736,6 +1736,7 @@ export class WarehouseInventoryService {
const [row] = await this.dataSource.query(
`SELECT inv.id,
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.quantity,
inv.weight,
inv.status,
@@ -1777,8 +1778,10 @@ export class WarehouseInventoryService {
throw new BadRequestException('A release order must be issued before downloading the exit paper');
}
const reference = `REL-${id.slice(0, 8).toUpperCase()}`;
const bookingReference = row?.bookingReference || row?.bookingId || 'N/A';
const bookingReference = row?.bookingReference || 'N/A';
const reference =
row?.releaseOrderReference ||
(row?.bookingReference ? `REL-${String(row.bookingReference).replace(/^BK-?/i, '')}` : 'REL-N/A');
const issuedAt = new Date(row.releaseDate);
const html = this.buildReleaseDocumentHtml({
reference,
@@ -1796,6 +1799,7 @@ export class WarehouseInventoryService {
yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null,
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row?.status ?? null,
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
});
return {
@@ -2300,6 +2304,7 @@ export class WarehouseInventoryService {
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
clearanceStatus: string;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -2316,71 +2321,84 @@ export class WarehouseInventoryService {
minute: '2-digit',
});
const rows = [
['Booking reference', data.bookingReference],
['Customer', data.customerName],
['Booking status', data.bookingStatus],
['Freight type', data.freightType],
['Trade direction', data.tradeDirection],
['Container number', data.containerNumber],
['Cargo / goods', data.cargoDescription],
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Weight', `${data.weight.toLocaleString()} kg`],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory status', data.inventoryStatus],
['Inventory Status', data.inventoryStatus],
['Clearance Status', data.clearanceStatus],
];
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Release Exit Paper</title>
<title>Warehouse Gate Clearance / Release Order</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 18px 8px; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 18px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.ref { text-align: right; font-size: 13px; color: #475569; }
.ref strong { display: block; color: #0f172a; font-size: 18px; margin-top: 6px; }
.notice { margin: 22px 0; padding: 14px 16px; background: #ecfdf5; border: 1px solid #99f6e4; border-radius: 8px; font-weight: 700; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th { width: 32%; text-align: left; color: #475569; background: #f8fafc; }
th, td { border: 1px solid #cbd5e1; padding: 10px 12px; font-size: 13px; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-top: 42px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
.footer { margin-top: 28px; font-size: 11px; color: #64748b; line-height: 1.5; }
body { font-family: "Times New Roman", Georgia, serif; color: #111827; margin: 0; }
.doc { padding: 10px 8px 0; position: relative; }
.top { display: grid; grid-template-columns: 1fr auto; gap: 24px; border-bottom: 2px solid #14532d; padding-bottom: 14px; }
.brand { font-size: 13px; color: #14532d; text-transform: uppercase; letter-spacing: .1em; font-weight: 700; }
h1 { margin: 7px 0 0; font-size: 27px; line-height: 1.1; text-transform: uppercase; letter-spacing: .03em; }
.subtitle { margin-top: 6px; font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
.ref { text-align: right; font-size: 12px; color: #475569; min-width: 210px; }
.ref strong { display: block; color: #111827; font-size: 17px; margin: 4px 0 8px; }
.seal { position: absolute; right: 32px; top: 126px; width: 124px; height: 124px; border: 4px double #14532d; border-radius: 999px; color: #14532d; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 17px; line-height: 1.15; transform: rotate(-13deg); opacity: .86; text-transform: uppercase; }
.seal::before { content: ""; position: absolute; inset: 10px; border: 1px solid #14532d; border-radius: inherit; }
.notice { margin: 20px 154px 18px 0; padding: 13px 15px; background: #f0fdf4; border: 1px solid #86efac; border-left: 5px solid #14532d; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #14532d; text-transform: uppercase; letter-spacing: .08em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #334155; background: #f8fafc; font-weight: 700; }
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 12.5px; vertical-align: top; }
.clause { margin-top: 16px; border: 1px solid #cbd5e1; padding: 12px 14px; font-size: 12.5px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; margin-top: 42px; }
.line { border-top: 1px solid #111827; padding-top: 8px; font-size: 12px; color: #334155; }
.footer { margin-top: 22px; border-top: 1px solid #cbd5e1; padding-top: 9px; font-size: 10.5px; color: #475569; line-height: 1.45; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">EDR Warehouse Operations</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Gate Clearance / Release Order</h1>
<div class="subtitle">Official warehouse release and exit authorization</div>
</div>
<div class="ref">
Release reference
Document / Release No.
<strong>${esc(data.reference)}</strong>
Issued: ${esc(issuedAt)}
</div>
</div>
<div class="seal">EDR<br />Warehouse<br />Cleared</div>
<div class="notice">
This document authorizes the listed booking/goods to leave the warehouse after release checks.
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
</div>
<div class="section-title">Release Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Authorization Clause</div>
<div class="clause">
The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity,
cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
</div>
<div class="signatures">
<div class="line">Warehouse officer name / signature / date</div>
<div class="line">Authorized warehouse officer name / signature / date</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
<div class="footer">
Present this release paper at the warehouse gate. Gate staff should verify booking reference,
customer/driver identity, cargo details, and any unpaid blocking fees before exit.
Present this original release order at the warehouse gate. This document is valid only for the booking/goods stated above and must be retained or recorded by gate operations according to warehouse procedure.
</div>
</div>
</body>

View File

@@ -28,6 +28,22 @@ export interface PayInvoiceDto {
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
export interface InvoiceDocumentDetails {
bookingReference: string | null;
customerName: string | null;
inventoryReference: string | null;
inventoryInfo: string | null;
inventoryStatus: string | null;
containerNumber: string | null;
cargoDescription: string | null;
clearanceStatus: string;
warehouseName: string | null;
yardName: string | null;
zoneName: string | null;
}
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
@Injectable()
export class WarehouseInvoiceService {
constructor(
@@ -152,16 +168,18 @@ export class WarehouseInvoiceService {
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoice & { items: unknown[] }> {
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] };
const details = await this.getInvoiceDocumentDetails(invoice);
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
}
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE');
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),
@@ -173,7 +191,8 @@ export class WarehouseInvoiceService {
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException('A receipt is available only after payment is recorded.');
}
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT');
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
return {
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
@@ -257,9 +276,66 @@ export class WarehouseInvoiceService {
}
}
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
CONCAT_WS(
' / ',
NULLIF(inv.status, ''),
NULLIF(COALESCE(container.container_number, booking_container.container_number), ''),
NULLIF(COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description), '')
) AS "inventoryInfo",
wh.name AS "warehouseName",
yard.name AS "yardName",
zone.name AS "zoneName",
CASE
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
ELSE 'PENDING PAYMENT'
END AS "clearanceStatus"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
WHERE fee.id = $1
LIMIT 1`,
[invoice.id, invoice.status],
);
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
inventoryReference: row?.inventoryReference ?? null,
inventoryInfo: row?.inventoryInfo ?? null,
inventoryStatus: row?.inventoryStatus ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
warehouseName: row?.warehouseName ?? null,
yardName: row?.yardName ?? null,
zoneName: row?.zoneName ?? null,
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
};
}
private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoice & { items: unknown[] },
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT',
details: InvoiceDocumentDetails,
): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -329,8 +405,13 @@ export class WarehouseInvoiceService {
<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>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>

View File

@@ -100,20 +100,33 @@ export class WarehouseReleaseDocumentService {
private htmlToBasicPdfBuffer(html: string): Buffer {
const text = this.htmlToPlainText(html);
const lines = this.wrapLines(text, 92).slice(0, 72);
const lines = this.wrapLines(text, 86).slice(0, 52);
const body = lines
.map((line, index) => {
const prefix = index === 0 ? '50 790 Td' : '0 -12 Td';
return `${prefix} (${this.escapePdfText(line)}) Tj`;
const y = 770 - index * 12;
const isTitle = index < 2 || /clearance|release order/i.test(line);
const size = index === 0 ? 13 : isTitle ? 11 : 9.6;
const font = isTitle ? 'F2' : 'F1';
return this.textOp(line, 48, y, size, font);
})
.join('\n');
const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`;
const stream = [
this.lineOp(48, 752, 548, 752),
this.circularSealOps(470, 690),
body,
this.lineOp(48, 126, 238, 126, '0 0 0'),
this.textOp('Authorized warehouse officer name / signature / date', 48, 110, 9, 'F1'),
this.lineOp(312, 126, 548, 126, '0 0 0'),
this.textOp('Customer or driver name / signature / date', 312, 110, 9, 'F1'),
this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'),
].join('\n');
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>',
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
];
@@ -178,4 +191,50 @@ export class WarehouseReleaseDocumentService {
private escapePdfText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
private textOp(
text: string,
x: number,
y: number,
size: number,
font: 'F1' | 'F2' = 'F1',
color = '0 0 0',
): string {
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
}
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string {
return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
}
private circularSealOps(cx: number, cy: number): string {
return [
'q',
'0.08 0.32 0.18 RG',
'0.08 0.32 0.18 rg',
'2.2 w',
this.circlePath(cx, cy, 51),
'S',
'0.8 w',
this.circlePath(cx, cy, 41),
'S',
this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'),
this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'),
this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'),
'Q',
].join('\n');
}
private circlePath(cx: number, cy: number, r: number): string {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
'h',
].join('\n');
}
}