diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 62530611c..5e1f46ad0 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -17,10 +17,23 @@ jobs:
outputs:
matrix: ${{ steps.filter.outputs.matrix }}
steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 2
+ # Plain git instead of actions/checkout: self-hosted runners on this
+ # network intermittently time out downloading action tarballs from
+ # codeload.github.com (100s HttpClient limit x3 = dead job). git fetch
+ # talks to github.com directly and needs no action download at all.
+ - name: Checkout (plain git, depth 2)
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ git init -q .
+ git remote remove origin 2>/dev/null || true
+ git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
+ git fetch -q --depth 2 origin "${{ github.sha }}"
+ git checkout -q --force "${{ github.sha }}"
+ git clean -ffdq
+ # Don't leave the token in .git/config on the persistent runner workspace.
+ git remote set-url origin "https://github.com/${{ github.repository }}.git"
- name: Determine changed services
id: filter
@@ -103,8 +116,20 @@ jobs:
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- - name: Checkout
- uses: actions/checkout@v4
+ # Same rationale as detect-changes: no action download on this network.
+ - name: Checkout (plain git)
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ git init -q .
+ git remote remove origin 2>/dev/null || true
+ git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
+ git fetch -q --depth 1 origin "${{ github.sha }}"
+ git checkout -q --force "${{ github.sha }}"
+ git clean -ffdq
+ # Don't leave the token in .git/config on the persistent runner workspace.
+ git remote set-url origin "https://github.com/${{ github.repository }}.git"
- name: Resolve project and build env file
run: |
diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts
index b4f168e4e..d2a88b683 100644
--- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts
+++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts
@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
.trim();
}
+/**
+ * Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
+ * drawn FIRST so the page content sits on top of it. 30-degree rotation via a
+ * text matrix; roughly centered on the page.
+ */
+export function watermarkOp(text: string, page: { width: number; height: number }): string {
+ const label = clipText(text, 46);
+ const size = 34;
+ const w = textWidth(label, size);
+ const x = page.width / 2 - (w * 0.866) / 2;
+ const y = page.height / 2 - (w * 0.5) / 2;
+ return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
+}
+
/**
* Parse a "summary tiles + one
+ notice + signature lines" document (the
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
@@ -150,11 +164,26 @@ export function htmlToText(html: string): string {
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
+ // Documents printed in duplicate wrap each copy in
+ // (freight order: Port Operations copy + Gate Security copy). Render one
+ // page per copy, each with its own watermark and tile set — parsing the
+ // whole HTML at once would merge both copies' tiles and drop the watermarks.
+ const copies = [...html.matchAll(/([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
+ const fragments = copies.length ? copies : [html];
+ return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
+}
+
+function buildTabularPageOps(
+ html: string,
+): Array<{ ops: string[]; page: { width: number; height: number } }> {
const pick = (re: RegExp) => html.match(re)?.[1];
const title = htmlToText(pick(/
]*>([\s\S]*?)<\/h1>/i) ?? "Document");
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\s\S]*?)<\/strong>/i) ?? "");
+ const metaLabel =
+ htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)]*>([\s\S]*?)<\/div>/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
@@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer {
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
- const ops: string[] = [];
+ const MAX_PAGES = 12;
- // Header
- ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
- ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
- ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
- if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
- if (metaRef) {
- ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
- ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
- }
- if (generated) {
- ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
- }
- ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
+ const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
+ let ops: string[] = [];
+ let y = 0;
- // Summary tiles
- let y = page.height - 100;
+ const drawFullHeader = () => {
+ ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
+ ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
+ ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
+ if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
+ if (metaRef) {
+ ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray));
+ ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
+ }
+ if (generated) {
+ ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
+ }
+ ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
+ y = page.height - 100;
+ };
+
+ const drawContinuationHeader = (pageNo: number) => {
+ ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6));
+ ops.push(
+ textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark),
+ );
+ if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray));
+ y = page.height - 54;
+ };
+
+ const startPage = (first: boolean) => {
+ ops = [];
+ if (watermark) ops.push(watermarkOp(watermark, page));
+ if (first) drawFullHeader();
+ else drawContinuationHeader(pagesOut.length + 1);
+ };
+
+ const finishPage = () => pagesOut.push({ ops, page });
+
+ startPage(true);
+
+ // Summary tiles (first page only)
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
@@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer {
y -= tileH + 12;
}
- // Table
+ // Table, paginated across as many pages as the rows need.
if (headers.length) {
const colW = contentW / headers.length;
const headerH = 16;
const rowH = 14;
const cellChars = Math.max(4, Math.floor(colW / 3.9));
- ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
- headers.forEach((h, c) =>
- ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
- );
- y -= headerH;
+ const bottomReserve = 46; // keep clear of the page edge on row-only pages
- let shown = 0;
- for (const row of rows) {
- if (y < 96) break;
+ const drawTableHeader = () => {
+ ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
+ headers.forEach((h, c) =>
+ ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
+ );
+ y -= headerH;
+ };
+
+ drawTableHeader();
+ let truncated = 0;
+ for (const [index, row] of rows.entries()) {
+ if (y - rowH < bottomReserve) {
+ if (pagesOut.length + 1 >= MAX_PAGES) {
+ truncated = rows.length - index;
+ break;
+ }
+ finishPage();
+ startPage(false);
+ drawTableHeader();
+ }
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
headers.forEach((_h, c) => {
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
@@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer {
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
});
y -= rowH;
- shown += 1;
}
- if (shown < rows.length) {
- ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
+ if (truncated > 0) {
+ ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
}
}
- // Notice (verification clause)
+ // Notice + signatures live on the final page; give them a fresh page when the
+ // rows ran too deep for the fixed bottom band.
+ if (y < 110 && (notice || signatures.length)) {
+ finishPage();
+ startPage(false);
+ }
if (notice) {
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
wrapText(notice, landscape ? 155 : 104)
.slice(0, 2)
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
}
-
- // Signatures
const sigW = contentW / signatures.length;
- signatures.forEach((s, i) => {
+ signatures.forEach((sig, i) => {
const x = M + i * sigW;
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
- ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
+ ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
+ finishPage();
- return assembleSinglePagePdf(ops, page);
+ return pagesOut;
}
/** Greedy word-wrap to a maximum character width. */
@@ -320,3 +390,41 @@ export function assembleSinglePagePdf(
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
+
+/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
+export function assemblePdf(
+ pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
+): Buffer {
+ const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
+ const objects: string[] = [
+ "<< /Type /Catalog /Pages 2 0 R >>",
+ `<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
+ ];
+ for (const [i, p] of pages.entries()) {
+ const stream = p.ops.join("\n");
+ objects.push(
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
+ );
+ objects.push(`<< /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");
+}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index 8f7a71fc1..281d3f620 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -1597,6 +1597,8 @@ export class WarehouseInventoryService {
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
+ // Import GRN is issued automatically at train unload.
+ ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
@@ -1630,6 +1632,7 @@ export class WarehouseInventoryService {
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
+ grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
arrivedAt: now,
unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
@@ -2757,11 +2760,12 @@ export class WarehouseInventoryService {
const rows: Array<{ containerNumber: string; weightTons: string }> =
await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
- COALESCE(bcu.vgm_tons, 0) AS "weightTons"
+ MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
+ GROUP BY bcu.container_number
ORDER BY bcu.container_number`,
[bookingId],
);
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
index ff8dc0abd..55722f04a 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx
@@ -51,6 +51,7 @@ import { warehouseService } from '@/services/warehouse.service';
import type {
EligibleBooking,
InventoryInquiryFilter,
+ InventoryStatus,
InventoryInquiryResult,
ImportTrain,
ImportTrainItem,
@@ -63,6 +64,7 @@ import type {
WarehouseYard,
WarehouseZone,
} from '@/types/warehouse';
+import { InventoryStatusBadge } from './badges';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { ContainerItemsModal } from './ContainerItemsModal';
@@ -253,6 +255,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
});
+
+
+
+const SUB_STAGE_COLOR: Record = {
+ PENDING: 'gray',
+ RECEIVED: 'blue',
+ GRN: 'teal',
+ ASSIGNED: 'indigo',
+ LOADED: 'grape',
+ LEFT: 'orange',
+ DELIVERED: 'green',
+};
+
+/**
+ * Expanded booking row: the booking's containers / bulk items with their
+ * lifecycle stage. Shares the ['container-items', bookingId] cache with
+ * ContainerItemsModal, so expanding after using the modal is instant.
+ */
+function BookingItemsExpansion({
+ bookingId,
+ colSpan,
+ bulkFallback,
+}: {
+ bookingId: string | null;
+ colSpan: number;
+ bulkFallback?: string;
+}) {
+ const { data: items = [], isLoading } = useQuery({
+ queryKey: ['container-items', bookingId],
+ queryFn: () => warehouseService.getContainerItems(bookingId as string),
+ enabled: Boolean(bookingId),
+ });
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : items.length === 0 ? (
+
+ {bulkFallback ?? 'No container units recorded on this booking.'}
+
+ ) : (
+