Merge pull request #308 from Tria-plc/marshaling

Marshaling
This commit is contained in:
Hagernesh Tadesse
2026-06-27 01:24:32 +03:00
committed by GitHub
8 changed files with 279 additions and 68 deletions

View File

@@ -1736,6 +1736,7 @@ export class WarehouseInventoryService {
const [row] = await this.dataSource.query( const [row] = await this.dataSource.query(
`SELECT inv.id, `SELECT inv.id,
inv.release_date AS "releaseDate", inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.quantity, inv.quantity,
inv.weight, inv.weight,
inv.status, inv.status,
@@ -1777,8 +1778,10 @@ export class WarehouseInventoryService {
throw new BadRequestException('A release order must be issued before downloading the exit paper'); 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 || 'N/A';
const bookingReference = row?.bookingReference || row?.bookingId || '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 issuedAt = new Date(row.releaseDate);
const html = this.buildReleaseDocumentHtml({ const html = this.buildReleaseDocumentHtml({
reference, reference,
@@ -1796,6 +1799,7 @@ export class WarehouseInventoryService {
yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null, yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null,
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row?.status ?? null, inventoryStatus: row?.status ?? null,
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
}); });
return { return {
@@ -2300,6 +2304,7 @@ export class WarehouseInventoryService {
yard: string | null; yard: string | null;
zone: string | null; zone: string | null;
inventoryStatus: string | null; inventoryStatus: string | null;
clearanceStatus: string;
}): string { }): string {
const esc = (value: unknown) => const esc = (value: unknown) =>
String(value ?? '-') String(value ?? '-')
@@ -2316,71 +2321,87 @@ export class WarehouseInventoryService {
minute: '2-digit', minute: '2-digit',
}); });
const rows = [ const rows = [
['Booking reference', data.bookingReference], ['Booking Reference', data.bookingReference],
['Customer', data.customerName], ['Customer / Consignee', data.customerName],
['Booking status', data.bookingStatus], ['Booking Status', data.bookingStatus],
['Freight type', data.freightType], ['Freight Type', data.freightType],
['Trade direction', data.tradeDirection], ['Trade Direction', data.tradeDirection],
['Container number', data.containerNumber], ['Container Number', data.containerNumber],
['Cargo / goods', data.cargoDescription], ['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity], ['Quantity', data.quantity],
['Weight', `${data.weight.toLocaleString()} kg`], ['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Warehouse', data.warehouse], ['Warehouse', data.warehouse],
['Yard', data.yard], ['Yard', data.yard],
['Zone', data.zone], ['Zone', data.zone],
['Inventory status', data.inventoryStatus], ['Inventory Status', data.inventoryStatus],
['Clearance Status', data.clearanceStatus],
]; ];
return `<!doctype html> return `<!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Warehouse Release Exit Paper</title> <title>Warehouse Gate Clearance / Release Order</title>
<style> <style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; } body { font-family: "Times New Roman", Georgia, serif; color: #111827; margin: 0; }
.doc { padding: 18px 8px; } .doc { padding: 10px 8px 0; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 18px; } .top { display: grid; grid-template-columns: 1fr auto; gap: 24px; border-bottom: 2px solid #14532d; padding-bottom: 14px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; } .brand { font-size: 13px; color: #14532d; text-transform: uppercase; letter-spacing: .1em; font-weight: 700; }
h1 { margin: 8px 0 0; font-size: 30px; } h1 { margin: 7px 0 0; font-size: 27px; line-height: 1.1; text-transform: uppercase; letter-spacing: .03em; }
.ref { text-align: right; font-size: 13px; color: #475569; } .subtitle { margin-top: 6px; font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
.ref strong { display: block; color: #0f172a; font-size: 18px; margin-top: 6px; } .ref { text-align: right; font-size: 12px; color: #475569; min-width: 210px; }
.notice { margin: 22px 0; padding: 14px 16px; background: #ecfdf5; border: 1px solid #99f6e4; border-radius: 8px; font-weight: 700; } .ref strong { display: block; color: #111827; font-size: 17px; margin: 4px 0 8px; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; } .seal { position: absolute; right: 8px; top: -42px; width: 112px; height: 112px; 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: 15px; line-height: 1.15; transform: rotate(-13deg); opacity: .86; text-transform: uppercase; }
th { width: 32%; text-align: left; color: #475569; background: #f8fafc; } .seal::before { content: ""; position: absolute; inset: 10px; border: 1px solid #14532d; border-radius: inherit; }
th, td { border: 1px solid #cbd5e1; padding: 10px 12px; font-size: 13px; } .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; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; margin-top: 42px; } .section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #14532d; text-transform: uppercase; letter-spacing: .08em; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; } table { width: 100%; border-collapse: collapse; }
.footer { margin-top: 28px; font-size: 11px; color: #64748b; line-height: 1.5; } 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; }
.officer-signature { position: relative; min-height: 98px; padding-right: 132px; }
.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> </style>
</head> </head>
<body> <body>
<div class="doc"> <div class="doc">
<div class="top"> <div class="top">
<div> <div>
<div class="brand">EDR Warehouse Operations</div> <div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Release / Exit Paper</h1> <h1>Warehouse Gate Clearance / Release Order</h1>
<div class="subtitle">Official warehouse release and exit authorization</div>
</div> </div>
<div class="ref"> <div class="ref">
Release reference Document / Release No.
<strong>${esc(data.reference)}</strong> <strong>${esc(data.reference)}</strong>
Issued: ${esc(issuedAt)} Issued: ${esc(issuedAt)}
</div> </div>
</div> </div>
<div class="notice"> <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>
<div class="section-title">Release Particulars</div>
<table> <table>
<tbody> <tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')} ${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody> </tbody>
</table> </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="signatures">
<div class="line">Warehouse officer name / signature / date</div> <div class="officer-signature">
<div class="seal">EDR<br />Warehouse<br />Cleared</div>
<div class="line">Officer in charge name / signature / date</div>
</div>
<div class="line">Customer or driver name / signature / date</div> <div class="line">Customer or driver name / signature / date</div>
</div> </div>
<div class="footer"> <div class="footer">
Present this release paper at the warehouse gate. Gate staff should verify booking reference, 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.
customer/driver identity, cargo details, and any unpaid blocking fees before exit.
</div> </div>
</div> </div>
</body> </body>

View File

@@ -28,6 +28,22 @@ export interface PayInvoiceDto {
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', '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() @Injectable()
export class WarehouseInvoiceService { export class WarehouseInvoiceService {
constructor( constructor(
@@ -152,16 +168,18 @@ export class WarehouseInvoiceService {
} }
// ── Reads ──────────────────────────────────────────────────────────────── // ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoice & { items: unknown[] }> { async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
const invoice = await this.invoiceRepository.findById(id); const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); 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 }> { async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id); 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 { return {
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`, filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html), buffer: await this.documents.htmlToPdfBuffer(html),
@@ -173,7 +191,8 @@ export class WarehouseInvoiceService {
if (Number(invoice.paidAmount) <= 0) { if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException('A receipt is available only after payment is recorded.'); 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 { return {
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html), 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( private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoice & { items: unknown[] }, invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT', kind: 'INVOICE' | 'RECEIPT',
details: InvoiceDocumentDetails,
): string { ): string {
const esc = (value: unknown) => const esc = (value: unknown) =>
String(value ?? '-') String(value ?? '-')
@@ -329,8 +405,13 @@ export class WarehouseInvoiceService {
<div class="summary"> <div class="summary">
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div> <div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
<div><span>Invoice type</span>${esc(invoice.invoiceType.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>Booking reference</span>${esc(details.bookingReference)}</div>
<div><span>Inventory ID</span>${esc(invoice.inventoryId)}</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>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><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
</div> </div>

View File

@@ -100,20 +100,33 @@ export class WarehouseReleaseDocumentService {
private htmlToBasicPdfBuffer(html: string): Buffer { private htmlToBasicPdfBuffer(html: string): Buffer {
const text = this.htmlToPlainText(html); 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 const body = lines
.map((line, index) => { .map((line, index) => {
const prefix = index === 0 ? '50 790 Td' : '0 -12 Td'; const y = 770 - index * 12;
return `${prefix} (${this.escapePdfText(line)}) Tj`; 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'); .join('\n');
const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`; const stream = [
this.lineOp(48, 752, 548, 752),
body,
this.circularSealOps(184, 154),
this.lineOp(48, 92, 278, 92, '0 0 0'),
this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'),
this.lineOp(326, 92, 548, 92, '0 0 0'),
this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'),
this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'),
].join('\n');
const objects = [ const objects = [
'<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /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 /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 /Helvetica >>', '<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>',
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`, `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
]; ];
@@ -178,4 +191,50 @@ export class WarehouseReleaseDocumentService {
private escapePdfText(value: string): string { private escapePdfText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); 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');
}
} }

View File

@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 5183", "dev": "vite --port 5183",
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 5183", "preview": "vite preview --port 5183",
"lint": "eslint src", "lint": "eslint src",

View File

@@ -24,6 +24,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
} }
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) { export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
const bookingReference = item?.booking?.reference ?? '-';
const inventorySummary = [
item?.status?.replace(/_/g, ' '),
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
]
.filter(Boolean)
.join(' / ');
return ( return (
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl"> <Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
{!item ? ( {!item ? (
@@ -33,10 +42,10 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Group justify="space-between" align="flex-start"> <Group justify="space-between" align="flex-start">
<Stack gap={2}> <Stack gap={2}>
<Text size="lg" fw={800}> <Text size="lg" fw={800}>
{item.booking?.reference ?? item.bookingId ?? item.id} {bookingReference}
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Inventory ID: {item.id} {inventorySummary || 'Inventory information'}
</Text> </Text>
</Stack> </Stack>
<InventoryStatusBadge status={item.status} /> <InventoryStatusBadge status={item.status} />
@@ -51,12 +60,12 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Divider label="Booking & item" labelPosition="left" /> <Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}> <SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={bookingReference} />
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} /> <DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} /> <DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} /> <DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Container ID" value={item.containerId ?? '-'} /> <DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} /> <DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} /> <DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} /> <DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} /> <DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />

View File

@@ -93,6 +93,16 @@ const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
buildCircularSeal(452, 155, label), buildCircularSeal(452, 155, label),
]; ];
const buildWarehouseOfficerSealBand = () => [
lineOp(60, 218, 535, 218),
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
buildCircularSeal(170, 128, 'CLEARED'),
textOp('Officer in charge name / signature / date:', 292, 154, 10),
lineOp(292, 132, 535, 132, '0 0 0'),
textOp('Customer or driver name / signature / date:', 292, 94, 10),
lineOp(292, 72, 535, 72, '0 0 0'),
];
function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
let y = 800; let y = 800;
const streamLines = lines.map((line) => { const streamLines = lines.map((line) => {
@@ -130,12 +140,22 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') { export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
const paid = kind === 'RECEIPT' || invoice.status === 'PAID'; const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`; const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
const bookingReference = firstText(invoice.bookingReference);
const customerName = firstText(invoice.customerName);
const inventoryReference = firstText(invoice.inventoryReference);
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
const clearanceStatus = firstText(
invoice.clearanceStatus,
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
);
const lines: PdfLine[] = [ const lines: PdfLine[] = [
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' }, { text: title, size: 23, bold: true, yGap: 28, align: 'center' },
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' }, { text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' }, { text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
{ text: `Booking: ${invoice.bookingId ?? '-'} Inventory: ${invoice.inventoryId}`, align: 'center' }, { text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' }, { text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' }, { text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
...(invoice.items ?? []).flatMap((item) => [ ...(invoice.items ?? []).flatMap((item) => [
@@ -201,15 +221,33 @@ export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | Warehous
const releasedItem = context.releasedItem; const releasedItem = context.releasedItem;
const inventory = context.inventory ?? releasedItem; const inventory = context.inventory ?? releasedItem;
const releasedAt = context.releasedAt ?? new Date(); const releasedAt = context.releasedAt ?? new Date();
const releaseReference = `REL-${invoice.inventoryId.slice(0, 8).toUpperCase()}`; const releaseReference = firstText(
inventory?.releaseOrderReference,
releasedItem?.releaseOrderReference,
invoice.inventoryReference,
booking?.reference ? `REL-${booking.reference.replace(/^BK-?/i, '')}` : null,
);
const customerName = firstText( const customerName = firstText(
booking?.company?.name, booking?.company?.name,
booking?.company?.companyName, booking?.company?.companyName,
booking?.company?.label, booking?.company?.label,
booking?.company?.contactPersonName, booking?.company?.contactPersonName,
invoice.customerId, invoice.customerName,
); );
const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight)); const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight));
const inventoryInfo = firstText(
invoice.inventoryInfo,
invoice.containerNumber,
invoice.cargoDescription,
inventory?.status,
releasedItem?.status,
);
const bookingReference = firstText(
booking?.reference,
(inventory as unknown as { bookingReference?: string })?.bookingReference,
invoice.bookingReference,
releasedItem?.booking?.reference,
);
return buildSimplePdf([ return buildSimplePdf([
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
@@ -217,21 +255,19 @@ export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | Warehous
{ text: '[ GATE CLEARANCE ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' }, { text: '[ GATE CLEARANCE ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
{ text: `Release Reference: ${releaseReference}`, bold: true, yGap: 30, align: 'center' }, { text: `Release Reference: ${releaseReference}`, bold: true, yGap: 30, align: 'center' },
{ text: `Invoice No: ${invoice.invoiceNumber}`, align: 'center' }, { text: `Invoice No: ${invoice.invoiceNumber}`, align: 'center' },
{ text: `Booking Reference: ${firstText(booking?.reference, (inventory as unknown as { bookingReference?: string })?.bookingReference, invoice.bookingId, releasedItem?.bookingId)}`, align: 'center' }, { text: `Booking Reference: ${bookingReference}`, align: 'center' },
{ text: `Customer: ${customerName}`, align: 'center' }, { text: `Customer: ${customerName}`, align: 'center' },
{ text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code, invoice.warehouseId, releasedItem?.warehouseId)}`, align: 'center' }, { text: `Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code, invoice.yardId, releasedItem?.yardId)}`, align: 'center' }, { text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code)}`, align: 'center' },
{ text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code, invoice.zoneId, releasedItem?.zoneId)}`, align: 'center' }, { text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code)}`, align: 'center' },
{ text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code)}`, align: 'center' },
{ text: `Booking Container: ${containerSummary(booking, inventory)}`, align: 'center' }, { text: `Booking Container: ${containerSummary(booking, inventory)}`, align: 'center' },
{ text: `Weight: ${weightTons}`, align: 'center' }, { text: `Weight: ${weightTons}`, align: 'center' },
{ text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}`, align: 'center' }, { text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}`, align: 'center' },
{ text: `Clearance: ${invoice.clearanceStatus ?? 'CLEARED FOR WAREHOUSE EXIT'}`, bold: true, color: 'green', align: 'center' },
{ text: `Release Date & Time: ${fmtDate(releasedAt)}`, align: 'center' }, { text: `Release Date & Time: ${fmtDate(releasedAt)}`, align: 'center' },
{ text: 'This document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' }, { text: 'This sealed document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' },
], [ ], [
...buildAuthorizationBand('CLEARED'), ...buildWarehouseOfficerSealBand(),
textOp('Warehouse officer name / signature / date:', 72, 190, 10),
lineOp(260, 188, 360, 188, '0 0 0'),
textOp('Customer or driver name / signature / date:', 72, 148, 10),
lineOp(260, 146, 360, 146, '0 0 0'),
]); ]);
} }

View File

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

View File

@@ -737,8 +737,16 @@ export interface WarehouseFeeInvoice {
id: string; id: string;
invoiceNumber: string; invoiceNumber: string;
bookingId?: string | null; bookingId?: string | null;
bookingReference?: string | null;
customerId?: string | null; customerId?: string | null;
customerName?: string | null;
inventoryId: string; inventoryId: string;
inventoryReference?: string | null;
inventoryInfo?: string | null;
inventoryStatus?: string | null;
containerNumber?: string | null;
cargoDescription?: string | null;
clearanceStatus?: string | null;
facilityId?: string | null; facilityId?: string | null;
warehouseId?: string | null; warehouseId?: string | null;
yardId?: string | null; yardId?: string | null;