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 26242aeba..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 }) { 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 6adbed278..f8cc32fe8 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", + COALESCE(inv.grn_number, 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< diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx new file mode 100644 index 000000000..9e017d5fc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx @@ -0,0 +1,252 @@ +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Loader, + Select, + Stack, + Table, + Tabs, + Text, +} from '@mantine/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { TrainFront } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service'; +import { extractErrorMessage } from './options'; + +const STAGE_COLOR: Record = { + RECEIVED: 'blue', + STORED: 'gray', + RESERVED: 'grape', + READY_FOR_LOADING: 'teal', + LOADED: 'green', +}; + +const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`); + +/** + * Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes + * assigned to it (stage tabs), multiselect the ready ones and load them onto their + * already-allocated wagons. Loading follows train + wagon allocation: only items + * that are READY_FOR_LOADING and have an allocated wagon are selectable. + */ +export function LoadToTrainPanel() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [scheduleId, setScheduleId] = useState(null); + const [tab, setTab] = useState('received'); + const [selected, setSelected] = useState([]); + + const trainsKey = ['loadable-trains']; + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + queryKey: trainsKey, + queryFn: () => warehouseService.getLoadableTrains(), + }); + + const itemsKey = ['train-loadable-items', scheduleId]; + const { data: items = [], isLoading } = useQuery({ + queryKey: itemsKey, + queryFn: () => warehouseService.getTrainLoadableItems(scheduleId as string), + enabled: Boolean(scheduleId), + }); + + const received = useMemo(() => items.filter((i) => i.status !== 'LOADED'), [items]); + const loaded = useMemo(() => items.filter((i) => i.status === 'LOADED'), [items]); + const visible = tab === 'loaded' ? loaded : received; + + const trainOptions = trains.map((t) => ({ + value: t.scheduleId, + label: + `${t.trainNumber ?? t.scheduleId.slice(0, 8)}` + + (t.origin || t.destination ? ` · ${t.origin ?? '?'}→${t.destination ?? '?'}` : '') + + ` · ${t.readyCount} ready / ${t.loadedCount} loaded`, + })); + + const selectableVisible = visible.filter((i) => i.loadable); + const allSelected = + selectableVisible.length > 0 && selectableVisible.every((i) => selected.includes(i.id)); + const toggle = (id: string) => + setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); + const toggleAll = () => + setSelected((s) => + allSelected + ? s.filter((id) => !selectableVisible.some((i) => i.id === id)) + : Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])), + ); + + const loadMutation = useMutation({ + mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected), + onSuccess: (r) => { + queryClient.invalidateQueries({ queryKey: itemsKey }); + queryClient.invalidateQueries({ queryKey: trainsKey }); + setSelected([]); + toast({ + title: 'Loaded onto train', + description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`, + }); + }, + onError: (e) => + toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), + }); + + const renderRow = (i: TrainLoadableItem) => ( + + + toggle(i.id)} + disabled={!i.loadable} + /> + + + {i.containerNumber ?? i.cargoType ?? '—'} + + {i.cargoType ?? '—'} + {weight(i.weight)} + + + {i.status.replace(/_/g, ' ')} + + + + {i.wagonNumber ? ( + + {i.wagonNumber} + + ) : ( + + Not allocated + + )} + + {i.bookingReference ?? '—'} + {i.customerName ?? '—'} + + {i.inspectionStatus ? ( + + {i.inspectionStatus} + + ) : ( + '—' + )} + + + ); + + return ( + + +