diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 47d8f5b3c..9e05eb794 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -105,9 +105,14 @@ export default registerAs("database", (): TypeOrmModuleOptions => { password: process.env.DB_PASSWORD ?? "", database: process.env.DB_NAME ?? "edr_freight", schema: "public", - extra: { - options: `-c search_path=${APPLICATION_SEARCH_PATH}`, - }, + // The `-c search_path=...` startup option is rejected by transaction-pooling + // poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When + // behind such a pooler set DB_PGBOUNCER=true and instead make the search_path + // a role default: ALTER ROLE IN DATABASE SET search_path TO + // public,iam,freight,audit; + ...(process.env.DB_PGBOUNCER === "true" + ? {} + : { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }), entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], autoLoadEntities: true, migrations: [ diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index a07087f8f..06d164bbd 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -1,6 +1,16 @@ import { Injectable } from "@nestjs/common"; import { PdfRenderService } from "./pdf-render.service"; +import { + PdfColor, + assembleSinglePagePdf, + lineOp, + rectOp, + sealOp, + textOp, + textOpRight, + wrapText, +} from "./styled-pdf.util"; export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; @@ -62,10 +72,136 @@ export class InvoiceDocumentService { const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; return { filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, - buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }), + buffer: await this.pdf.htmlToPdfBuffer(html, { + label: `${model.title} ${kindLabel}`, + // Chromium-less fallback: draw a genuine styled invoice (header, seal, + // summary grid, line-item table, totals) from the model — not a flat + // plain-text dump — so it still reads as a proper invoice document. + fallback: () => this.buildFallbackPdf(model), + }), }; } + /** + * Vector-drawn styled invoice/receipt used when headless Chromium is + * unavailable. Mirrors the HTML layout closely enough to pass as the same + * document. Single A4 page; long summaries / line lists are capped to fit. + */ + buildFallbackPdf(model: InvoiceDocumentModel): Buffer { + const currency = (cur?: string | null) => + (cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency); + const money = (amount: unknown, cur?: string | null) => + `${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`; + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; + + const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`; + const sealText = + model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + const showCategory = Boolean(model.categoryHeader); + + const ops: string[] = []; + + // ── Header ──────────────────────────────────────────────────────────── + ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4)); + ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray)); + const titleSize = heading.length > 34 ? 18 : 22; + ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark)); + + ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray)); + ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark)); + ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray)); + ops.push( + textOpRight( + `Status ${model.status}`, + 559, + 748, + 8.5, + "F1", + model.status === "PAID" ? PdfColor.teal : PdfColor.gray, + ), + ); + ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1)); + + // ── Seal ────────────────────────────────────────────────────────────── + ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal)); + + // ── Summary grid (two columns) ──────────────────────────────────────── + let y = 700; + const colX = [36, 300]; + const colW = 250; + model.summary.slice(0, 16).forEach((row, i) => { + const x = colX[i % 2]; + if (i % 2 === 0 && i > 0) y -= 27; + ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray)); + ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark)); + ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6)); + }); + y -= 34; + + // ── Line-item table ─────────────────────────────────────────────────── + const qtyR = 402; + const rateR = 486; + const amtR = 555; + ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7)); + ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray)); + if (showCategory) { + ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray)); + } + ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray)); + ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray)); + ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray)); + y -= 18; + + const descChars = showCategory ? 44 : 66; + for (const item of model.lines) { + if (y < 190) break; // leave room for totals + footer + const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2); + const rowH = Math.max(18, descLines.length * 10 + 8); + ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6)); + descLines.forEach((line, k) => { + ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark)); + }); + if (showCategory) { + ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark)); + } + ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark)); + ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark)); + ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark)); + y -= rowH; + } + + // ── Totals ──────────────────────────────────────────────────────────── + let ty = y - 16; + for (const total of model.totals) { + if (ty < 88) break; + if (total.grand) { + ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9)); + ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark)); + ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark)); + ty -= 24; + } else { + ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray)); + ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark)); + ty -= 17; + } + } + + // ── Footer ──────────────────────────────────────────────────────────── + ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8)); + ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray)); + ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8)); + ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray)); + + return assembleSinglePagePdf(ops); + } + + /** Truncate to `max` chars with an ellipsis. */ + private clip(value: string, max: number): string { + const text = String(value ?? ""); + return text.length > max ? `${text.slice(0, max - 3)}...` : text; + } + buildHtml(model: InvoiceDocumentModel): string { const esc = (value: unknown) => String(value ?? "-") 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 new file mode 100644 index 000000000..8eb90172e --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -0,0 +1,173 @@ +/** + * Minimal hand-built PDF primitives shared by the Chromium-less document + * fallbacks (invoices, receipts). These draw a genuine vector layout — boxes, + * rules, right-aligned money, a round seal — so a document still looks like a + * real document when headless Chromium is unavailable, instead of degrading to + * a flat plain-text dump. Coordinates are PDF user space (origin bottom-left, + * A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold. + */ + +export const MIN_VALID_PDF_BYTES = 2_000; + +/** Colours as PDF "r g b" triples in the 0..1 range. */ +export const PdfColor = { + teal: "0.06 0.46 0.43", + dark: "0.06 0.09 0.16", + gray: "0.39 0.45 0.55", + line: "0.80 0.84 0.89", + shade: "0.96 0.97 0.98", + tint: "0.94 0.99 0.98", +} as const; + +export function escapePdfText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)") + .replace(/[^\x20-\x7e]/g, " "); +} + +/** Approximate rendered width of Helvetica text (slightly over-estimated so + * right-aligned text never crosses its column edge). */ +export function textWidth(text: string, size: number): number { + return text.length * size * 0.52; +} + +export function textOp( + text: string, + x: number, + y: number, + size: number, + font: "F1" | "F2" = "F1", + color: string = PdfColor.dark, +): string { + return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`; +} + +/** Right-align `text` so it ends at `rightX`. */ +export function textOpRight( + text: string, + rightX: number, + y: number, + size: number, + font: "F1" | "F2" = "F1", + color: string = PdfColor.dark, +): string { + return textOp(text, rightX - textWidth(text, size), y, size, font, color); +} + +export function lineOp( + x1: number, + y1: number, + x2: number, + y2: number, + color: string = PdfColor.line, + width = 0.8, +): string { + return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; +} + +export function rectOp( + x: number, + y: number, + width: number, + height: number, + fillColor = "1 1 1", + strokeColor: string = PdfColor.line, + lineWidth = 0.7, +): string { + return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`; +} + +function 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"); +} + +/** A double-ring round rubber-stamp seal carrying up to three centred lines. */ +export function sealOp( + cx: number, + cy: number, + r: number, + lines: string[], + color: string = PdfColor.teal, +): string { + const rows = lines.slice(0, 3); + const ops = [ + "q", + `${color} RG`, + `${color} rg`, + "2 w", + circlePath(cx, cy, r), + "S", + "0.7 w", + circlePath(cx, cy, r - 6), + "S", + ]; + const startY = cy + (rows.length - 1) * 6; + rows.forEach((text, i) => { + const size = i === 0 ? 10 : 7.5; + ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color)); + }); + ops.push("Q"); + return ops.join("\n"); +} + +/** Greedy word-wrap to a maximum character width. */ +export function wrapText(text: string, maxChars: number): string[] { + const out: string[] = []; + for (const raw of String(text ?? "").split("\n")) { + const words = raw.split(/\s+/).filter(Boolean); + let line = ""; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > maxChars && line) { + out.push(line); + line = word; + } else { + line = next; + } + } + if (line) out.push(line); + } + return out.length ? out : [""]; +} + +/** Assemble a single-page A4 PDF from content-stream ops (Helvetica fonts). */ +export function assembleSinglePagePdf(ops: string[]): Buffer { + const stream = ops.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 /F2 5 0 R >> >> /Contents 6 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + `<< /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.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index cd1aa432c..84baaf4da 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -100,6 +100,27 @@ export class WarehouseInventoryController { return this.inventoryService.loadedExport(); } + @Get('loadable-trains') + @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) + loadableTrains() { + return this.inventoryService.loadableTrains(); + } + + @Get('train/:scheduleId/loadable-items') + @ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' }) + trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.inventoryService.trainLoadableItems(scheduleId); + } + + @Post('train/:scheduleId/load') + @ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' }) + loadItemsOntoTrain( + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body() dto: { inventoryIds: string[]; performedBy?: string }, + ) { + return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy); + } + @Post('bulk-dispatch-export') @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { @@ -333,6 +354,12 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Get('bookings/:bookingId/container-items') + @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) + containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.containerItems(bookingId); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { 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 8e9457831..bf7139d72 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 @@ -273,6 +273,45 @@ export interface BulkDispatchResult { results: { inventoryId: string; status: string; reason?: string }[]; } +/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */ +export interface LoadableTrainRow { + scheduleId: string; + trainNumber: string | null; + origin: string | null; + destination: string | null; + status: string; + departureTime: string | Date | null; + /** Received/ready inventory not yet loaded onto this train. */ + readyCount: number; + /** Inventory already loaded onto this train. */ + loadedCount: number; +} + +/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */ +export interface TrainLoadableItemRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + grnNumber: string | null; + inspectionStatus: string | null; + status: string; + wagonId: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + /** True only when the item is READY_FOR_LOADING and has an allocated wagon. */ + loadable: boolean; +} + +export interface TrainLoadResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + export interface AutoUnloadArrivedResult { unloadedCount: number; skippedCount: number; @@ -1070,6 +1109,169 @@ export class WarehouseInventoryService { return this.exportInventoryByStatus('LOADED'); } + // ── Per-train loading (Load to Train tab) ───────────────────────────────── + // Loading follows wagon allocation: staff pick an allocated EXPORT train, see + // the arrived containers/cargoes assigned to it, and load the ready ones onto + // their already-allocated wagons. Reuses the single-item load() machinery. + + /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ + async loadableTrains(): Promise { + const rows: Array< + LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT ts.id AS "scheduleId", + ts.train_number AS "trainNumber", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + ts.status AS "status", + ts.scheduled_departure_date AS "departureTime", + (SELECT count(*) FROM freight.train_schedule_bookings tsb + JOIN freight.warehouse_inventory inv + ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", + (SELECT count(*) FROM freight.train_schedule_bookings tsb + JOIN freight.warehouse_inventory inv + ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + AND inv.status = 'LOADED') AS "loadedCount" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status = ANY($1) + AND EXISTS ( + SELECT 1 FROM freight.train_schedule_bookings tsb2 + JOIN freight.warehouse_inventory inv2 + ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL + WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL + AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + ) + ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, + [['DRAFT', 'SCHEDULED']], + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({ + ...rest, + readyCount: Number(rest.readyCount) || 0, + loadedCount: Number(rest.loadedCount) || 0, + })); + } + + /** + * Container/cargo inventory items assigned to a train, with the wagon each is + * allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING) + * plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill. + */ + async trainLoadableItems(scheduleId: string): Promise { + const rows: Array> = await this.dataSource.query( + `SELECT inv.id AS "id", + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + company.name AS "customerName", + ct.container_number AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", + inv.inspection_status AS "inspectionStatus", + inv.status AS "status", + wl.wagon_id AS "wagonId", + wl.wagon_number AS "wagonNumber", + wl.sequence_no AS "sequenceNo" + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN LATERAL ( + SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw + ON tsw.id = wba.train_set_wagon_id + AND tsw.train_set_id = ts.train_set_id + AND tsw.deleted_at IS NULL + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL + ORDER BY tsw.sequence_no ASC NULLS LAST + LIMIT 1 + ) wl ON true + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, + [scheduleId], + ); + + return rows.map((r) => ({ + ...r, + loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId), + })); + } + + /** + * Load the selected inventory items onto their allocated wagons for the given + * train. Each item must be assigned to this train, READY_FOR_LOADING, and have + * an allocated wagon; others are skipped with a reason. When every inventory + * item of a booking is loaded, its train_schedule_bookings.loading_status flips + * to LOADED so the train's confirm-loading/dispatch step reflects reality. + */ + async loadItemsOntoTrain( + scheduleId: string, + inventoryIds: string[], + performedBy?: string, + ): Promise { + const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; + const items = await this.trainLoadableItems(scheduleId); + const byId = new Map(items.map((i) => [i.id, i])); + const affectedBookingIds = new Set(); + + for (const inventoryId of inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + const item = byId.get(inventoryId); + if (!item) { skip('Not assigned to this train'); continue; } + if (item.status === 'LOADED') { skip('Already loaded'); continue; } + if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; } + if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } + + try { + await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy }); + result.loadedCount += 1; + result.results.push({ inventoryId, status: 'LOADED' }); + if (item.bookingId) affectedBookingIds.add(item.bookingId); + } catch (error) { + skip(error instanceof Error ? error.message : 'Load failed'); + } + } + + // Flip a booking's train loading_status to LOADED once no un-loaded inventory remains. + for (const bookingId of affectedBookingIds) { + await this.dataSource.query( + `UPDATE freight.train_schedule_bookings tsb + SET loading_status = 'LOADED', updated_at = NOW() + WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL + AND inv.status NOT IN ('LOADED', 'DISPATCHED') + )`, + [scheduleId, bookingId], + ); + } + + return result; + } + /** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */ private async importQueueByStatuses(statuses: string[]): Promise { const rows: Array< @@ -2308,6 +2510,95 @@ export class WarehouseInventoryService { }; } + /** + * Per-container (or bulk) items of a booking with their lifecycle stage and + * reference sources — drives the container-level detail datatable (stage tabs, + * multiselect load-to-truck, per-item actions). + */ + async containerItems(bookingId: string): Promise< + Array<{ + containerNumber: string; + goods: string | null; + stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; + grnNumber: string | null; + truckAssignmentId: string | null; + truckPlate: string | null; + truckArrived: boolean; + truckLeft: boolean; + bookingReference: string | null; + contractId: string | null; + hasLastMile: boolean; + }> + > { + const rows: Array<{ + containerNumber: string; + goods: string | null; + received: boolean; + grnNumber: string | null; + truckAssignmentId: string | null; + truckPlate: string | null; + truckArrived: boolean; + truckLeft: boolean; + bookingReference: string | null; + contractId: string | null; + hasLastMile: boolean; + delivered: boolean; + }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + bcu.received_to_port AS received, + bcu.grn_number AS "grnNumber", + ctc.assignment_id AS "truckAssignmentId", + a.plate_number AS "truckPlate", + (a.arrived_at IS NOT NULL) AS "truckArrived", + (a.departed_at IS NOT NULL) AS "truckLeft", + b.reference AS "bookingReference", + b.contract_id AS "contractId", + (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", + COALESCE(inv.status = 'DELIVERED', false) AS delivered + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + JOIN freight.bookings b ON b.id = bc.booking_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.customer_truck_containers ctc + ON ctc.container_number = bcu.container_number + AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL + LEFT JOIN freight.customer_truck_assignments a + ON a.id = ctc.assignment_id AND a.deleted_at IS NULL + LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number + LEFT JOIN freight.warehouse_inventory inv + ON inv.container_id = cont.id AND inv.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + ORDER BY bcu.container_number`, + [bookingId], + ); + + return rows.map((r) => ({ + containerNumber: r.containerNumber, + goods: r.goods, + stage: r.delivered + ? 'DELIVERED' + : r.truckLeft + ? 'LEFT' + : r.truckAssignmentId + ? 'LOADED' + : r.grnNumber + ? 'GRN' + : r.received + ? 'RECEIVED' + : 'PENDING', + grnNumber: r.grnNumber, + truckAssignmentId: r.truckAssignmentId, + truckPlate: r.truckPlate, + truckArrived: r.truckArrived, + truckLeft: r.truckLeft, + bookingReference: r.bookingReference, + contractId: r.contractId, + hasLastMile: r.hasLastMile, + })); + } + /** * Per-truck exit paper: one paper covering the containers loaded on a specific * customer truck (used when multiple trucks leave separately). Gated on the diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx new file mode 100644 index 000000000..168bfd744 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -0,0 +1,211 @@ +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Loader, + Modal, + Select, + Stack, + Table, + Tabs, + Text, +} from '@mantine/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { FileText } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { + warehouseService, + type ContainerItem, + type ContainerItemStage, +} from '@/services/warehouse.service'; +import { extractErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; + +interface ContainerItemsModalProps { + opened: boolean; + onClose: () => void; + bookingId: string | null; + bookingReference?: string | null; +} + +const STAGE_TABS: Array<{ value: string; label: string }> = [ + { value: 'ALL', label: 'All' }, + { value: 'RECEIVED', label: 'Received' }, + { value: 'GRN', label: "GRN'd" }, + { value: 'LOADED', label: 'Loaded' }, + { value: 'LEFT', label: 'Left' }, + { value: 'DELIVERED', label: 'Delivered' }, +]; + +const STAGE_COLOR: Record = { + PENDING: 'gray', + RECEIVED: 'blue', + GRN: 'teal', + LOADED: 'grape', + LEFT: 'orange', + DELIVERED: 'green', +}; + +/** Loadable = not yet on a truck (before LOADED). */ +const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN'; + +export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [tab, setTab] = useState('ALL'); + const [selected, setSelected] = useState([]); + const [truckId, setTruckId] = useState(null); + + const itemsKey = ['container-items', bookingId]; + const { data: items = [], isLoading } = useQuery({ + queryKey: itemsKey, + queryFn: () => warehouseService.getContainerItems(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + const { data: trucks = [] } = useQuery({ + queryKey: ['ci-trucks', bookingId], + queryFn: () => warehouseService.getCustomerTrucks(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + + const visible = useMemo( + () => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)), + [items, tab], + ); + const truckOptions = trucks + .filter((t) => !(t as { departedAt?: string }).departedAt) + .map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` })); + + const loadMutation = useMutation({ + mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: itemsKey }); + setSelected([]); + toast({ title: 'Containers loaded onto truck' }); + }, + onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), + }); + + const openExitPaper = async (assignmentId: string, plate: string) => { + try { + const res = await warehouseService.downloadTruckExitPaper(assignmentId); + openPdfBlob(res.data, `exit-${plate}.pdf`); + } catch (e) { + toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) }); + } + }; + + const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n])); + + return ( + Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}} + > + setTab(v ?? 'ALL')} mb="sm"> + + {STAGE_TABS.map((t) => { + const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length; + return ( + {count}}> + {t.label} + + ); + })} + + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + No container or bulk items on this booking. + ) : ( + + + + + + + Container + Goods + Stage + Truck + Booking + Contract + Last mile + Actions + + + + {visible.map((i) => ( + + + toggle(i.containerNumber)} + disabled={!isLoadable(i)} + /> + + {i.containerNumber} + {i.goods ?? '—'} + {i.stage} + {i.truckPlate ?? '—'} + {i.bookingReference ?? '—'} + {i.contractId ? Contract : '—'} + {i.hasLastMile ? EDR : Self-haul} + + {i.truckAssignmentId && ( + + )} + + + ))} + +
+
+ + {/* Multiselect → load onto a truck */} + + {selected.length} selected + + { + setScheduleId(v); + setSelected([]); + setTab('received'); + }} + disabled={trainOptions.length === 0} + leftSection={} + w={460} + searchable + /> + + + {!scheduleId ? ( + + Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only + after train and wagon allocation. + + ) : ( + <> + setTab(v ?? 'received')}> + + + {received.length} + + } + > + Received + + + {loaded.length} + + } + > + Loaded + + + + + {isLoading ? ( + + + + ) : visible.length === 0 ? ( + + {tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'} + + ) : ( + + + + + + {tab === 'received' && ( + 0} + onChange={toggleAll} + disabled={selectableVisible.length === 0} + /> + )} + + Container / Cargo + Goods + Weight + Stage + Wagon + Booking + Customer + Inspection + + + {visible.map(renderRow)} +
+
+ )} + + {tab === 'received' && ( + + + {selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded + + + + )} + + )} +
+ ); +} 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 e1e12e915..83c7fcbcd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -62,6 +62,7 @@ import type { import { BookingSelect } from './BookingSelect'; import { DeliverInventoryModal } from './DeliverInventoryModal'; import { TruckDispatchModal } from './TruckDispatchModal'; +import { ContainerItemsModal } from './ContainerItemsModal'; import { FeePreviewModal } from './FeePreviewModal'; import { InspectionReportModal } from './InspectionReportModal'; import { InventoryDetailModal } from './InventoryDetailModal'; @@ -2161,6 +2162,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const [releaseItem, setReleaseItem] = useState(null); const [deliverItem, setDeliverItem] = useState(null); const [loadTruckItem, setLoadTruckItem] = useState(null); + const [containerItemsItem, setContainerItemsItem] = useState(null); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -2376,7 +2378,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { - setViewItem(toInventoryItem(r))}> + setContainerItemsItem(toInventoryItem(r))}> @@ -2500,6 +2502,12 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { bookingId={loadTruckItem?.booking?.id ?? null} bookingReference={loadTruckItem?.booking?.reference ?? null} /> + setContainerItemsItem(null)} + bookingId={containerItemsItem?.booking?.id ?? null} + bookingReference={containerItemsItem?.booking?.reference ?? null} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx index f80db55f4..652a26863 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core'; -import { CreditCard, Eye, Truck } from 'lucide-react'; +import { CreditCard, Eye, Truck, TrainFront } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; @@ -10,6 +10,7 @@ import { VisualEmptyState, formatNumber, } from '@/components/warehouses'; +import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; @@ -116,6 +117,9 @@ export default function LoadingQueuePage() { > Dispatch Queue + }> + Load to Train + {/* Ready to Load — PAID bookings, can be marked Loaded */} @@ -169,6 +173,11 @@ export default function LoadingQueuePage() { )} + + {/* Load to Train — per-train arrived containers/cargoes, multiselect → load onto wagons */} + + + diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 4d7a834ba..16037b199 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -63,6 +63,58 @@ import type { WarehouseZone, } from '@/types/warehouse'; +export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; + +export interface ContainerItem { + containerNumber: string; + goods: string | null; + stage: ContainerItemStage; + grnNumber: string | null; + truckAssignmentId: string | null; + truckPlate: string | null; + truckArrived: boolean; + truckLeft: boolean; + bookingReference: string | null; + contractId: string | null; + hasLastMile: boolean; +} + +/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */ +export interface LoadableTrain { + scheduleId: string; + trainNumber: string | null; + origin: string | null; + destination: string | null; + status: string; + departureTime: string | null; + readyCount: number; + loadedCount: number; +} + +/** A container/cargo inventory item assigned to a train, with its allocated wagon. */ +export interface TrainLoadableItem { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + grnNumber: string | null; + inspectionStatus: string | null; + status: string; + wagonId: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + loadable: boolean; +} + +export interface TrainLoadResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + const cleanParams = (params: object) => Object.fromEntries( Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null), @@ -75,6 +127,14 @@ export const warehouseService = { return data?.data ?? data ?? []; }, + /** Per-container/bulk items of a booking with lifecycle stage + refs. */ + getContainerItems: async (bookingId: string): Promise => { + const { data } = await apiClient.get( + `/warehouse-inventory/bookings/${bookingId}/container-items`, + ); + return data?.data ?? data ?? []; + }, + /** Booking container numbers not yet loaded onto any truck. */ getLoadableContainers: async (bookingId: string): Promise => { const { data } = await apiClient.get( @@ -96,6 +156,33 @@ export const warehouseService = { return data?.data ?? data ?? []; }, + // ── Load to Train ───────────────────────────────────────────────────────── + /** Pre-dispatch EXPORT trains with inventory waiting to be loaded. */ + getLoadableTrains: async (): Promise => { + const { data } = await apiClient.get('/warehouse-inventory/loadable-trains'); + return data?.data ?? data ?? []; + }, + + /** Container/cargo items assigned to a train, with allocated wagon + stage. */ + getTrainLoadableItems: async (scheduleId: string): Promise => { + const { data } = await apiClient.get( + `/warehouse-inventory/train/${scheduleId}/loadable-items`, + ); + return data?.data ?? data ?? []; + }, + + /** Load selected inventory items onto their allocated wagons for a train. */ + loadItemsOntoTrain: async ( + scheduleId: string, + inventoryIds: string[], + ): Promise => { + const { data } = await apiClient.post( + `/warehouse-inventory/train/${scheduleId}/load`, + { inventoryIds }, + ); + return data?.data ?? data ?? { loadedCount: 0, skippedCount: 0, results: [] }; + }, + // ── Warehouses ────────────────────────────────────────────────────────── list: (filter?: WarehouseFilter) => apiClient.get(URL_CONSTANTS.WAREHOUSES.BASE, {