diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e707967e8..5e1f46ad0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,21 +17,23 @@ jobs: outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: - - name: Harden git for flaky self-hosted network + # 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: | - git config --global http.version HTTP/1.1 - git config --global http.postBuffer 524288000 - git config --global http.lowSpeedLimit 0 - git config --global http.lowSpeedTime 999999 - - - name: Checkout - uses: actions/checkout@v4 - with: - # Depth 2 so the changed-services diff (HEAD^..HEAD) works. - fetch-depth: 2 - # Partial clone: fetch blobs lazily instead of one huge pack — avoids - # the "Recv failure / early EOF" resets on the self-hosted runner. - filter: blob:none + 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 @@ -114,20 +116,20 @@ jobs: COMPOSE_DOCKER_CLI_BUILD: "1" steps: - - name: Harden git for flaky self-hosted network + # Same rationale as detect-changes: no action download on this network. + - name: Checkout (plain git) + env: + GH_TOKEN: ${{ github.token }} run: | - git config --global http.version HTTP/1.1 - git config --global http.postBuffer 524288000 - git config --global http.lowSpeedLimit 0 - git config --global http.lowSpeedTime 999999 - - - name: Checkout - uses: actions/checkout@v4 - with: - # Deploy only needs the current tree, not full history — the default - # (unlimited depth) fetched a huge pack and reset on this runner. - fetch-depth: 1 - filter: blob:none + 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/.gitignore b/.gitignore index ffdc4b78b..ca2a5b7af 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ coverage/ *~ \#*\# .\#* +docker-compose.override.yml diff --git a/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts b/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts new file mode 100644 index 000000000..aafa9715f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `company_profiles.status` defaulted to 'active', so any insert that omitted + * the column produced an operational role that was approved without ever being + * reviewed. Every live write path already passes 'pending' explicitly; this + * closes the hole at the schema level. + * + * Deliberately no data backfill. A role approved through setCompanyProfileStatus + * always stamps `reviewed_at`, so `status = 'active' AND reviewed_at IS NULL` + * flags a role that skipped review — but it also matches rows approved before + * `reviewed_at` existed (migration 2000000000001). Auditing that set is a + * judgement call about real customers, not something to automate here. + */ +export class CompanyProfileDefaultPending2100000000000 + implements MigrationInterface +{ + name = 'CompanyProfileDefaultPending2100000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts new file mode 100644 index 000000000..26afdf82a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Auto-load onto a selected train: a warehouse_loadings row now records WHICH + * train the item was loaded onto (train_schedule_id), and wagon_id becomes + * nullable because a schedule-level load may not resolve to a single wagon. + */ +export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface { + name = 'WarehouseLoadingTrainAssociation2100000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings + ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL + `); + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings + ALTER COLUMN wagon_id DROP NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule + ON freight.warehouse_loadings(train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`); + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id + `); + // wagon_id stays nullable on revert: restoring NOT NULL would fail on rows + // recorded without a wagon and re-introduce the outage this fixes. + } +} 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/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 4b3e3bcca..96ed3b701 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { BookingContractService } from './booking-contract.service'; @@ -68,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckContainer, ]), BillingModule, + DocumentsModule, NotificationsModule, NotificationInboxModule, forwardRef(() => FirstMileModule), diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index dac739fbf..9f72c7409 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -50,7 +50,8 @@ import { Booking } from './entities/booking.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { @@ -99,7 +100,7 @@ export class BookingsService { private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, private readonly vehiclesService: VehiclesService, - private readonly contractPdfService: ContractPdfService, + private readonly pdfRender: PdfRenderService, private readonly events: EventEmitter2, ) {} @@ -170,7 +171,12 @@ export class BookingsService { ); const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); - const buffer = await this.contractPdfService.htmlToPdfBuffer(html); + // Chromium when available; otherwise the styled tabular fallback (never the + // generic text dump — the freight order is an outward-facing gate document). + const buffer = await this.pdfRender.htmlToPdfBuffer(html, { + label: 'freight order', + fallback: (prepared) => buildTabularFallbackPdf(prepared), + }); return { filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, buffer, @@ -262,21 +268,10 @@ export class BookingsService { containers: string | null; }>, ): string { + const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') : '-'; - const bookingRows: Array<[string, string | null | undefined]> = [ - ['Booking Reference', booking.reference], - ['Client Name', booking.company?.name], - ['Client ID', booking.companyId], - ['Trade Direction', booking.tradeDirection], - ['Freight Type', booking.freightType], - ['Assigned At', assignedAt], - ['Booking Status', booking.status], - ]; - const bookingRowHtml = bookingRows - .map(([label, value]) => `
`) - .join(''); // Fall back to the legacy single-truck booking columns when there are no // multi-truck rows (bookings assigned before the multi-truck feature). @@ -297,44 +292,63 @@ export class BookingsService { ] : []; - const truckBlocks = truckList - .map((t, i) => { - const rows: Array<[string, string | null | undefined]> = [ - ['Truck Plate Number', t.plateNumber], - ['Driver Name', t.driverName], - ['Truck Type', t.truckType], - ['Containers Loaded', t.containers], - [ - 'Arrival', - t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival', - ], - ]; - const html = rows - .map( - ([label, value]) => - ``, - ) - .join(''); - return `

Truck ${i + 1}

${this.escapeHtml(label)}${this.escapeHtml(value || '-')}
${this.escapeHtml(label)}${this.escapeHtml(value || '-')}
${html}
`; - }) + const truckRows = truckList + .map( + (t, i) => ` + ${i + 1} + ${esc(t.plateNumber)} + ${esc(t.driverName)} + ${esc(t.truckType)} + ${esc(t.containers)} + ${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'} + `, + ) .join(''); const copy = (watermark: string) => `
-
${this.escapeHtml(watermark)}
-
+
${esc(watermark)}
+
+
Ethio-Djibouti Railway S.C.

Freight Order

-

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}

+
Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}
- ${this.escapeHtml(booking.reference)} -
- ${bookingRowHtml}
- ${truckBlocks} +
+ Booking + ${esc(booking.reference)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+ +
+
Client${esc(booking.company?.name)}
+
Client ID${esc(booking.companyId)}
+
Trade direction${esc(booking.tradeDirection)}
+
Freight type${esc(booking.freightType)}
+
Assigned at${esc(assignedAt)}
+
Booking status${esc(booking.status)}
+
+ + + + + + + + + + + + ${truckRows} +
#Truck plateDriverTruck typeContainers loadedArrival
+
+ Present this freight order at the warehouse gate. Each truck may only collect the + containers listed against it; the handover must be signed before any truck leaves. +
-
Customer / Carrier Signature
-
Port Operations Verification
-
Gate Security Verification
+
Customer / Carrier signature — date
+
Port operations verification — date
+
Gate security verification — date
`; @@ -342,21 +356,30 @@ export class BookingsService { + Freight Order diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index f8fbb26b0..8db9eba66 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -34,7 +34,10 @@ import { ResponseCompanyDto, ResponseCompanyProfileDto, } from "./dto/response-company.dto"; -import { ProfileLicenseFileView } from "./entities/company-profile.entity"; +import { + CompanyDocumentFileView, + ProfileLicenseFileView, +} from "./entities/company-profile.entity"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -218,7 +221,7 @@ export class CompaniesController { @Post("company-profile") @ApiOperation({ summary: - "Create a single operational profile for the current user's company and make it the active mode", + "Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", }) async createCompanyProfile( @CurrentUser() user: CurrentIamUser, @@ -306,6 +309,49 @@ export class CompaniesController { return this.companiesService.listProfileLicenseFiles(user.id, profileId); } + @Get("poa-delegation") + @ApiOperation({ + summary: + "List the Power of Attorney delegation letter (with review state) for the current user's company", + }) + async listPoaDelegation( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.listPoaDelegationFiles(user.id); + } + + @Post("poa-delegation") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: + "Upload the Power of Attorney delegation letter, replacing any existing one. " + + "For an approved company the upload is staged for backoffice review; during " + + "onboarding it goes live.", + }) + async uploadPoaDelegation( + @CurrentUser() user: CurrentIamUser, + @UploadedFiles() files: Array, + ): Promise { + const file = files?.[0]; + if (!file) { + throw new BadRequestException("A delegation letter file is required"); + } + return this.companiesService.uploadPoaDelegationLetter(user.id, file); + } + + @Delete("poa-delegation/:fileId") + @ApiOperation({ + summary: + "Remove the Power of Attorney delegation letter (staged for review on an approved company).", + }) + async removePoaDelegation( + @CurrentUser() user: CurrentIamUser, + @Param("fileId", ParseUUIDPipe) fileId: string, + ): Promise { + return this.companiesService.removePoaDelegationLetter(user.id, fileId); + } + @Patch("active-mode") @ApiOperation({ summary: "Switch the current user's active operational mode (importer/exporter)", diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 646d01d47..73826689a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,9 +1,11 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; import { MinioModule } from "../minio/minio.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { CompaniesController } from "./companies.controller"; import { CompaniesService } from "./companies.service"; import { CompaniesRepository } from "./companies.repository"; @@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; +import { CompanyNotifierService } from "./company-notifier.service"; @Module({ imports: [ @@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service"; FilesModule, FileUploadSettingsModule, MinioModule, + // Account-status notifications (CompanyNotifierService). The inbox module + // imports this module back for portal recipient targeting, hence forwardRef. + NotificationsModule, + forwardRef(() => NotificationInboxModule), ], controllers: [CompaniesController], providers: [ @@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service"; CompanyChangeRequestRepository, CompanyDashboardRepository, ETradeService, + CompanyNotifierService, ], exports: [ CompaniesService, diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index e31851aef..1027de955 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; +import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -37,6 +38,7 @@ import { import { ExternalProfile } from "./entities/external-profile.entity"; import { BusinessLicenseFile, + CompanyDocumentFileView, CompanyProfile, ProfileLicenseFileView, ProfileType, @@ -45,6 +47,7 @@ import { import { ChangeRequestStatus, CompanyChangeRequest, + DocumentChangeIntent, LicenseChangeIntent, } from "./entities/company-change-request.entity"; @@ -54,6 +57,27 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; +/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ +const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; +/** Code for a PoA letter staged in an open change request (not yet live). */ +const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; +/** FileRecord resource that company-level documents are stored under. */ +const COMPANY_RESOURCE = "companies"; +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_ATTRIBUTES = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; +/** Mandatory once the company operates as a freight forwarder. */ +const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ + { key: "poaName", label: "PoA name" }, + { key: "poaEmail", label: "PoA email" }, + { key: "poaPhone", label: "PoA phone" }, +]; + export interface UserIdentity { userId: string; firstName: string; @@ -73,6 +97,7 @@ export class CompaniesService { private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, + private readonly companyNotifier: CompanyNotifierService, ) { } /** @@ -562,9 +587,13 @@ export class CompaniesService { } async updateCompany(id: string, dto: UpdateCompanyDto): Promise { - await this.findCompanyById(id); + const before = await this.findCompanyById(id); const updated = await this.companiesRepo.update(id, dto); if (!updated) throw new NotFoundException(`Company ${id} not found`); + + // Suspending or blacklisting locks the customer out, so they must be told. + // This is the only path that writes those statuses. + this.companyNotifier.statusChanged(updated, before.status); return updated; } @@ -765,6 +794,7 @@ export class CompaniesService { const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); await this.companiesRepo.update(company.id, companyUpdates); await this.applyLicenseChanges(request); + await this.applyDocumentChanges(request); return ( (await this.changeRequestRepo.update(id, { @@ -817,7 +847,12 @@ export class CompaniesService { if (existing) { const prev = existing.documents?.documentFileIds ?? []; await this.changeRequestRepo.update(existing.id, { - documents: { documentFileIds: [...prev, ...fileIds] }, + // Spread the existing documents blob: a bare object would drop any + // licenseChanges/documentChanges already staged on this request. + documents: { + ...existing.documents, + documentFileIds: [...prev, ...fileIds], + }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, note: null, @@ -849,12 +884,17 @@ export class CompaniesService { ); } await this.discardLicenseChanges(request); + await this.discardDocumentChanges(request); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, - // Staged license uploads were just discarded; drop their intents so an - // amended resubmit never re-references deleted files. - documents: { ...request.documents, licenseChanges: [] }, + // Staged license/document uploads were just discarded; drop their intents + // so an amended resubmit never re-references deleted files. + documents: { + ...request.documents, + licenseChanges: [], + documentChanges: [], + }, note, reviewedBy: reviewerId ?? null, reviewedAt: new Date(), @@ -1017,13 +1057,12 @@ export class CompaniesService { ); } - const reference = await this.companyProfilesRepo.generateReference(type); - + // No reference is minted here: it is issued by setCompanyProfileStatus when + // a reviewer approves the role. Creating it Active would bypass that review. return this.companyProfilesRepo.create({ companyId, type, - reference, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } @@ -1097,9 +1136,11 @@ export class CompaniesService { } /** - * Create a single operational profile for the current user's company and - * make it the active mode in the same call. Powers the header "Switch to - * Exporter/Importer" flow when the target profile doesn't exist yet. + * Create a single operational profile for the current user's company. The new + * role starts Pending, so it deliberately does NOT become the active mode: + * switching onto an unapproved profile would strip the user of `canBook` and + * block them from creating contracts under the role they already had approved. + * Callers switch explicitly via {@link setActiveMode} once the role is Active. */ async createCompanyProfileForUser( userId: string, @@ -1122,8 +1163,7 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created) { // New self-service roles start Pending (awaiting backoffice approval) and - // carry no reference until approved. The customer can select this mode but - // can't book under it until it's cleared. + // carry no reference until approved. created = await this.companyProfilesRepo.create({ companyId, type, @@ -1132,8 +1172,6 @@ export class CompaniesService { }); } - await this.profilesRepo.update(profile.id, { activeProfileType: type }); - return created; } @@ -1240,6 +1278,29 @@ export class CompaniesService { ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + // 4. Power of Attorney. Optional in general, but a freight forwarder acts on + // other companies' behalf so its PoA is mandatory. Either way, a PoA that + // has been entered must be evidenced by the delegation letter. + const poaRequired = (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ); + const poaProvided = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + const missingPoaFields = poaRequired + ? REQUIRED_POA_FIELDS.filter( + (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), + ) + : []; + // Only gate on the letter once the document set actually carries the field. + const delegationField = (setting?.fields ?? []).find( + (f) => f.fileKey === POA_DELEGATION_FILE_KEY, + ); + const missingDelegation = + Boolean(delegationField) && + (poaRequired || poaProvided) && + !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), @@ -1247,18 +1308,31 @@ export class CompaniesService { (p) => `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, ), + ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), + ...(missingDelegation + ? ["Upload the delegation letter for your Power of Attorney"] + : []), ]; // Progress spans every required item the user has to satisfy: company-info - // fields, required documents and one license per operational profile. + // fields, required documents, one license per operational profile, and the + // PoA details/letter whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; + const poaItemCount = + (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + + (delegationField && (poaRequired || poaProvided) ? 1 : 0); const total = this.REQUIRED_COMPANY_INFO.length + requiredDocCount + - licenseProfiles.length; + licenseProfiles.length + + poaItemCount; const completed = total - - (missingInfo.length + missingDocs.length + missingLicenses.length); + (missingInfo.length + + missingDocs.length + + missingLicenses.length + + missingPoaFields.length + + (missingDelegation ? 1 : 0)); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1266,6 +1340,13 @@ export class CompaniesService { companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, documents, licenseProfiles, + poa: { + required: poaRequired, + provided: poaProvided, + delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + missingFields: missingPoaFields, + complete: missingPoaFields.length === 0 && !missingDelegation, + }, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, @@ -1365,10 +1446,12 @@ export class CompaniesService { // browser (which fails on the internal bucket endpoint). /** - * Upload business-license file(s) for one of the user's profiles. During - * onboarding (company not yet Active) they go live immediately; for an Active - * company they're staged under the pending code and recorded as `add` intents - * on a pending change request for backoffice review. Returns the updated view. + * Upload business-license file(s) for one of the user's profiles. For a role + * not yet approved (a fresh onboarding profile, or a newly added service on an + * already-active company) they go live immediately and are reviewed together + * with the role itself. Only for an already-approved role are they staged under + * the pending code and recorded as `add` intents on a pending change request — + * a licence swap on a live role is a change; a licence on a new role is not. */ async addProfileLicenseFiles( userId: string, @@ -1377,7 +1460,7 @@ export class CompaniesService { ): Promise { const profile = await this.resolveOwnedProfile(userId, profileId); const company = await this.findCompanyById(profile.companyId); - const gated = company.status === CompanyStatus.Active; + const gated = profile.status === ProfileStatus.Active; const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE; const uploaded = await Promise.all( @@ -1409,9 +1492,9 @@ export class CompaniesService { /** * Remove a license file. A staged (pending) file is withdrawn outright - * (soft-deleted, its `add` intent dropped). A live file on an Active company - * is kept and recorded as a `remove` intent for review; during onboarding it - * is deleted immediately. + * (soft-deleted, its `add` intent dropped). A live file on an already-approved + * role is kept and recorded as a `remove` intent for review; on a role still + * awaiting approval it is deleted immediately. */ async removeProfileLicenseFile( userId: string, @@ -1427,7 +1510,7 @@ export class CompaniesService { throw new NotFoundException(`License file ${fileId} not found`); } const company = await this.findCompanyById(profile.companyId); - const gated = company.status === CompanyStatus.Active; + const gated = profile.status === ProfileStatus.Active; if (record.code === LICENSE_PENDING_CODE) { // Withdraw a not-yet-approved upload: delete it and drop its add intent. @@ -1449,7 +1532,7 @@ export class CompaniesService { /** * Replace a live license file with a freshly uploaded one — recorded as a * `remove` of the old file plus an `add` of the new, so approval swaps them - * atomically. During onboarding the swap is applied immediately. + * atomically. On a role still awaiting approval the swap is applied immediately. */ async replaceProfileLicenseFile( userId: string, @@ -1463,7 +1546,7 @@ export class CompaniesService { throw new NotFoundException(`License file ${fileId} not found`); } const company = await this.findCompanyById(profile.companyId); - const gated = company.status === CompanyStatus.Active; + const gated = profile.status === ProfileStatus.Active; const created = await this.filesService.upload({ resourceId: profileId, @@ -1671,6 +1754,254 @@ export class CompaniesService { } } + // --------------------------------------------------------------------------- + // Power of Attorney delegation letter + // + // A company-level document that follows the same staged-review model as the + // business license: on an approved (Active) company an upload lands under the + // pending code and the live letter is flagged for removal, so the reviewer + // sees both and approval swaps them atomically. During onboarding it goes live. + // --------------------------------------------------------------------------- + + /** The company's PoA letter(s), with each file's review status resolved. */ + async listPoaDelegationFiles( + userId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + return this.getPoaDelegationView(company.id); + } + + /** + * Upload the PoA delegation letter, replacing whatever is already on file. + * On an Active company this stages an `add` for the new file plus a `remove` + * for each live one; a letter still awaiting approval is withdrawn outright + * rather than stacking a second pending upload. + */ + async uploadPoaDelegationLetter( + userId: string, + file: Express.Multer.File, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const gated = company.status === CompanyStatus.Active; + + const records = await this.filesService.findByResource( + company.id, + COMPANY_RESOURCE, + ); + const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY); + const staged = records.filter( + (r) => r.code === POA_DELEGATION_PENDING_CODE, + ); + + // Supersede an unreviewed upload instead of queueing another one. + for (const r of staged) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(company.id, r.id); + } + + const created = await this.filesService.upload({ + resourceId: company.id, + resource: COMPANY_RESOURCE, + code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY, + file, + }); + + if (gated) { + await this.stageDocumentIntent( + company.id, + [ + ...live.map((r) => ({ + op: "remove" as const, + fileId: r.id, + code: POA_DELEGATION_FILE_KEY, + fileName: r.name, + })), + { + op: "add" as const, + fileId: created.id, + code: POA_DELEGATION_FILE_KEY, + fileName: created.name, + }, + ], + userId, + ); + } else { + // Onboarding: no review, so the old letter is simply replaced. + for (const r of live) await this.filesService.remove(r.id); + } + + return this.getPoaDelegationView(company.id); + } + + /** + * Remove the PoA letter. A staged upload is withdrawn outright; a live file on + * an Active company is kept and flagged for deletion on approval; during + * onboarding it is deleted immediately. + */ + async removePoaDelegationLetter( + userId: string, + fileId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const record = await this.filesService.findById(fileId); + if ( + record.resource !== COMPANY_RESOURCE || + record.resourceId !== company.id || + (record.code !== POA_DELEGATION_FILE_KEY && + record.code !== POA_DELEGATION_PENDING_CODE) + ) { + throw new NotFoundException(`Delegation letter ${fileId} not found`); + } + + if (record.code === POA_DELEGATION_PENDING_CODE) { + await this.filesService.remove(fileId); + await this.withdrawDocumentIntent(company.id, fileId); + } else if (company.status === CompanyStatus.Active) { + await this.stageDocumentIntent( + company.id, + [ + { + op: "remove", + fileId, + code: POA_DELEGATION_FILE_KEY, + fileName: record.name, + }, + ], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getPoaDelegationView(company.id); + } + + private async getPoaDelegationView( + companyId: string, + ): Promise { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.documentChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + const records = await this.filesService.findByResource( + companyId, + COMPANY_RESOURCE, + ); + return records + .filter( + (r) => + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE, + ) + .map((r) => ({ + id: r.id, + name: r.name, + size: r.size, + mimeType: r.mimeType, + status: + r.code === POA_DELEGATION_PENDING_CODE + ? ("pending_add" as const) + : removeIds.has(r.id) + ? ("pending_remove" as const) + : ("live" as const), + })); + } + + /** Open or append a pending change request recording document add/remove intents. */ + private async stageDocumentIntent( + companyId: string, + changes: DocumentChangeIntent[], + submittedBy?: string, + ): Promise { + if (changes.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.documentChanges ?? []; + // Re-uploading twice before review would otherwise stage a second `remove` + // for the same live file, and the duplicate would fail on approval. + const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`)); + const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`)); + if (fresh.length === 0) return; + await this.changeRequestRepo.update(existing.id, { + documents: { + ...existing.documents, + documentChanges: [...prev, ...fresh], + }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { documentChanges: changes }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** + * Drop a staged document intent referencing `fileId`. If that empties the + * request entirely, delete it so the customer's settings page unlocks. + */ + private async withdrawDocumentIntent( + companyId: string, + fileId: string, + ): Promise { + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (!existing) return; + const remaining = (existing.documents?.documentChanges ?? []).filter( + (c) => c.fileId !== fileId, + ); + const docs = existing.documents ?? {}; + const stillHasWork = + remaining.length > 0 || + (docs.licenseChanges?.length ?? 0) > 0 || + (docs.documentFileIds?.length ?? 0) > 0 || + Object.keys(existing.snapshot ?? {}).length > 0; + + if (stillHasWork) { + await this.changeRequestRepo.update(existing.id, { + documents: { ...docs, documentChanges: remaining }, + }); + } else { + await this.changeRequestRepo.softDelete(existing.id); + } + } + + /** Apply a request's staged document changes: promote adds, delete removes. */ + private async applyDocumentChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.documentChanges ?? []) { + if (change.op === "add") { + await this.filesService.setCode(change.fileId, change.code); + } else { + await this.filesService.remove(change.fileId); + } + } + } + + /** Discard a rejected request's staged document uploads (adds only). */ + private async discardDocumentChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.documentChanges ?? []) { + if (change.op === "add") { + await this.filesService.remove(change.fileId); + } + } + } + /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → @@ -1719,13 +2050,17 @@ export class CompaniesService { } async fetchETradeData(tin: string) { - const { businessInfo } = await this.etradeService.resolveCompanyData(tin); + const { businessInfo, companyInfo } = + await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData( + businessInfo, + companyInfo, + ); const tinTaken = await this.companiesRepo.existsByTin(tin); return { ...registrationData, tinTaken }; } diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts new file mode 100644 index 000000000..167526988 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -0,0 +1,86 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + NotificationAudience, + NotificationPriority, + NotificationType, +} from "@edr/types"; + +import { Company, CompanyStatus } from "./entities/company.entity"; +import { NotificationsService } from "../notifications/notifications.service"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; + +/** Account statuses that lock the customer out and therefore must be told to them. */ +const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ + CompanyStatus.Suspended, + CompanyStatus.Blacklisted, +]; + +/** + * Customer notifications for company account-status changes. Mirrors + * {@link ContractNotifierService}: SMS + email direct to the company contact, + * plus a persisted in-app item. Every send is fire-and-forget and never throws — + * a notification failure must not roll back the status change itself. + */ +@Injectable() +export class CompanyNotifierService { + private readonly logger = new Logger(CompanyNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + /** Send SMS + email to the company contact; log-only on failure. */ + private async notifyContact(company: Company, message: string): Promise { + const phone = company.contactPersonPhone ?? company.phone ?? null; + const email = company.email ?? company.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend("sms", phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend("email", email, message); + } catch (err) { + this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${company.id} — not notified`); + } + } + + /** + * Tell the customer their account was suspended or blacklisted. Called only on + * a real transition into one of those statuses; other status writes are silent. + */ + statusChanged(company: Company, previous: CompanyStatus): void { + const status = company.status; + if (status === previous) return; + if (!PUNITIVE_STATUSES.includes(status)) return; + + const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; + const title = `Account ${label}`; + const body = + `Your company account has been ${label}. ` + + `You will not be able to submit new contracts or bookings. ` + + `Please contact EDR support for assistance.`; + + this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link: "/settings", + data: { companyId: company.id, status }, + priority: NotificationPriority.HIGH, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 579ac6ddd..4a931dae3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -1,6 +1,7 @@ import { ChangeRequestStatus, CompanyChangeRequest, + DocumentChangeIntent, LicenseChangeIntent, } from "../entities/company-change-request.entity"; @@ -18,6 +19,8 @@ export class ChangeRequestResponseDto { documentFileIds: string[]; /** Staged business-license add/remove intents attached to this request. */ licenseChanges: LicenseChangeIntent[]; + /** Staged company-document add/remove intents (e.g. the PoA letter). */ + documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; submittedAt: Date | null; @@ -33,6 +36,7 @@ export class ChangeRequestResponseDto { this.snapshot = req.snapshot ?? {}; this.documentFileIds = req.documents?.documentFileIds ?? []; this.licenseChanges = req.documents?.licenseChanges ?? []; + this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; this.submittedAt = req.submittedAt ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index ef7eb2a21..bfe1f9b72 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -1,6 +1,7 @@ import { CompanyRegistrationData } from "@edr/types"; export class ETradeResponseDto implements CompanyRegistrationData { + companyName!: string; licenceNumber!: string; statusDescription!: string; dateRegistered!: string; @@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { tinTaken?: boolean; constructor(data: CompanyRegistrationData) { + this.companyName = data.companyName; this.licenceNumber = data.licenceNumber; this.statusDescription = data.statusDescription; this.dateRegistered = data.dateRegistered; diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 92f9fa513..da908a177 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile { uploaded: boolean; } +export interface OnboardingPoaState { + /** True when the company operates as a freight forwarder — PoA is mandatory. */ + required: boolean; + /** True once any PoA detail has been entered. */ + provided: boolean; + /** True when the delegation letter is stored for the company. */ + delegationLetterUploaded: boolean; + /** PoA details still missing (only populated when `required`). */ + missingFields: OnboardingInfoField[]; + /** False while the PoA step still owes details or a delegation letter. */ + complete: boolean; +} + export class OnboardingRequirementsResponseDto { /** Resolved document setting code (by nationality) the docs were drawn from. */ documentSettingCode: string; @@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto { /** Per-operational-profile business-license requirements. */ licenseProfiles: OnboardingLicenseProfile[]; + /** Power of Attorney state, so the wizard needn't re-derive the rule. */ + poa: OnboardingPoaState; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto { this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; + this.poa = init.poa; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index cec670787..5ee6739de 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -30,12 +30,34 @@ export interface LicenseChangeIntent { fileName?: string; } +/** + * A staged change to a company-level document, awaiting review. Same semantics + * as {@link LicenseChangeIntent} but keyed by the document's FileRecord `code` + * (e.g. `poa_delegation_letter`) rather than a profile: `add` → uploaded under + * the pending code, promoted to `code` on approval; `remove` → a live file that + * is deleted on approval. A replace is a `remove` plus an `add`. + */ +export interface DocumentChangeIntent { + op: "add" | "remove"; + fileId: string; + /** The live FileRecord code this op targets (the upload setting's fileKey). */ + code: string; + /** File name, snapshotted for the backoffice review screen. */ + fileName?: string; +} + /** File references staged alongside a change request (documents/licenses). */ export interface ChangeRequestDocuments { - /** FileRecord ids uploaded against the company while this request was open. */ + /** + * FileRecord ids uploaded against the company while this request was open. + * These go live immediately — only their ids are recorded, for the reviewer. + * Contrast `documentChanges`, which stages the file behind the pending code. + */ documentFileIds?: string[]; /** Staged per-profile business-license add/remove intents. */ licenseChanges?: LicenseChangeIntent[]; + /** Staged company-level document add/remove intents (e.g. the PoA letter). */ + documentChanges?: DocumentChangeIntent[]; } @Entity({ schema: "freight", name: "company_change_request" }) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 2266b0c65..ebda7a0b9 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -31,17 +31,28 @@ export interface BusinessLicenseFile { mimeType?: string; } +/** + * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; + * `pending_remove` — live but flagged for deletion on approval. + */ +export type StagedFileStatus = "live" | "pending_add" | "pending_remove"; + /** A business-license file plus its change-review state, surfaced to clients. */ export interface ProfileLicenseFileView { id: string; name: string; size: number; mimeType: string; - /** - * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; - * `pending_remove` — live but flagged for deletion on approval. - */ - status: "live" | "pending_add" | "pending_remove"; + status: StagedFileStatus; +} + +/** A company-level document (e.g. the PoA letter) with its change-review state. */ +export interface CompanyDocumentFileView { + id: string; + name: string; + size: number; + mimeType: string; + status: StagedFileStatus; } @Entity({ schema: "freight", name: "company_profiles" }) @@ -73,11 +84,17 @@ export class CompanyProfile extends BaseEntity { }) reference!: string | null; + /** + * A newly requested operational role is unreviewed, so it defaults to Pending. + * Only {@link CompaniesService.setCompanyProfileStatus} may promote it to + * Active — an approved-by-default role would let a customer self-grant a + * service (e.g. importer) without any documentation review. + */ @Column({ name: "status", type: "varchar", length: 32, - default: ProfileStatus.Active, + default: ProfileStatus.Pending, }) status!: ProfileStatus; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 15054c701..b588c241f 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -87,12 +87,21 @@ export class ETradeService { } } + /** + * `companyInfo` carries the registered organization name (`BusinessName`); + * `businessInfo` only carries the licence's `TradeName`. Pass both so the + * company name resolves to the legal entity rather than the trade name — and + * never to `ManagerNameEng`, which is the manager's personal name. + */ extractRegistrationData( businessInfo: ETradeBusinessInfo, + companyInfo?: ETradeCompanyInfo, ): CompanyRegistrationData { const primaryManager = businessInfo.AssociateShortInfos?.[0]; return { + companyName: + companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "", licenceNumber: businessInfo.LicenceNumber, statusDescription: businessInfo.StatusDescription, dateRegistered: businessInfo.DateRegistered, diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 0ae829529..d0705bfb1 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository { }); } - /** GL queue: pending requests across all contracts, oldest first. */ - async findPending(): Promise { + /** + * GL queue: every request across all contracts, newest first. The queue page + * filters by status client-side (pending work vs accepted/rejected history), + * and surfaces the customer — so the contract's company rides along. + */ + async findQueue(): Promise { return this.repository.find({ - where: { status: 'PENDING' }, - order: { createdAt: 'ASC' }, - relations: { contract: true }, + order: { createdAt: 'DESC' }, + relations: { contract: { company: true } }, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index e038c7bff..17270c738 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -139,7 +139,7 @@ export class BookingRequestService { } queue(): Promise { - return this.repo.findPending(); + return this.repo.findQueue(); } private async findPending(requestId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 70083a2ae..9a03bfe8a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -218,7 +218,7 @@ export class ContractBookingService { contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, - equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN', originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 4ea7634b6..7f75b12c8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -107,7 +107,7 @@ export class ContractsController { @Get('booking-requests/queue') @BookingStaff(FREIGHT_PERMS.contracts.createBooking) - @ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' }) + @ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' }) bookingRequestQueue() { return this.bookingRequestService.queue(); } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index f220cae0d..870817365 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -4,6 +4,7 @@ import { IsArray, IsBoolean, IsDateString, + IsIn, IsInt, IsNumber, IsOptional, @@ -14,6 +15,9 @@ import { ValidateNested, } from 'class-validator'; +/** Per-shipment equipment return — "NA" stays contract-level only. */ +const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; + /** One physical container under a booking line — entered at booking time. */ export class CreateContainerUnitDto { @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' }) @@ -134,6 +138,15 @@ export class CreateBookingUnderContractDto { @IsDateString() scheduledDate?: string; + @ApiPropertyOptional({ + enum: SHIPMENT_EQUIPMENT_RETURNS, + description: + 'Per-shipment equipment return override; omitted → the contract default applies.', + }) + @IsOptional() + @IsIn([...SHIPMENT_EQUIPMENT_RETURNS]) + equipmentReturn?: string; + @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @IsOptional() @IsArray() diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index 4981a9486..d30ebe3a9 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; @@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service"; @Module({ imports: [ TypeOrmModule.forFeature([Notification, User, Session]), - // ExternalProfileRepository + CompanyProfileRepository (portal targeting) - CompaniesModule, + // ExternalProfileRepository + CompanyProfileRepository (portal targeting). + // CompaniesModule imports this module back for CompanyNotifierService. + forwardRef(() => CompaniesModule), // BackofficeService.getOrganizationEmployees (staff targeting) BackofficeModule, // EmailClientService + SmsClientService (HIGH-priority fan-out) diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index 23399bb4d..238129a1d 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -43,11 +43,26 @@ export class Route extends BaseEntity { * Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa", * not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the * machine identifier and is only a fallback for a yard missing one. + * + * When the route's milestones are loaded (with their yards), the label is the FULL + * ordered corridor — "Addis Ababa → Adama → Dire Dawa" — since milestones already + * include the origin (first) and destination (last). Without milestones it falls + * back to origin → destination. */ export function formatRouteLabel(route: { originYard?: { code?: string; label?: string } | null; destinationYard?: { code?: string; label?: string } | null; + milestones?: Array<{ + sequenceNo: number; + yard?: { code?: string; label?: string } | null; + }> | null; }): string { + const stops = [...(route.milestones ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((m) => m.yard?.label ?? m.yard?.code) + .filter((name): name is string => Boolean(name)); + if (stops.length >= 2) return stops.join(' → '); + const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin'; const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination'; return `${origin} → ${dest}`; diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index a64faabbe..6a8aced53 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -23,8 +23,9 @@ export class TrainSchedulesRepository extends BaseRepository { where: { id }, relations: { // Yards carry the route's display name; without them formatRouteLabel - // degrades to the literal "Origin → Destination". - route: { originYard: true, destinationYard: true }, + // degrades to the literal "Origin → Destination". Milestones (with + // their yards) give it the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, trainSet: { locomotive: true, locomotives: { locomotive: true }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 7dd78d167..d51108043 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -31,7 +31,6 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; -import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; @@ -568,7 +567,6 @@ export class BookingBatchService implements OnModuleInit { ); } - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); const required = need ?? this.needFor(booking, wagonDims); let corridorMatched = false; @@ -578,7 +576,7 @@ export class BookingBatchService implements OnModuleInit { ); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) continue; - const limits = await this.capacityLimits(locomotive, rules); + const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg @@ -748,8 +746,9 @@ export class BookingBatchService implements OnModuleInit { trainSet: { locomotive: true }, originStation: true, destinationStation: true, - // Yards supply the route's display name for `routeName` below. - route: { originYard: true, destinationYard: true }, + // Yards supply the route's display name for `routeName` below; + // milestones (with yards) give it the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, }, order: { [sortBy]: sortOrder } as never, skip: (page - 1) * pageSize, @@ -757,7 +756,6 @@ export class BookingBatchService implements OnModuleInit { }); const wagonDims = await this.loadWagonDims(); - const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const board: BatchBoardSchedule[] = []; @@ -787,7 +785,7 @@ export class BookingBatchService implements OnModuleInit { }; }); - board.push(this.buildScheduleSummary(s, items, rules)); + board.push(this.buildScheduleSummary(s, items)); } return { @@ -817,7 +815,6 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); - const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -1011,7 +1008,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1045,9 +1042,10 @@ export class BookingBatchService implements OnModuleInit { /** * Board capacity figures. `usedWeightTons` is GROSS (each item's weight already * includes the tare of the wagons it occupies), so the ceiling it is measured - * against must be the same one the fill loop spends from: the locomotive floored - * by the global rule caps and widened by its overage tolerance. Reading the raw - * `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use. + * against must be the same one the fill loop spends from: the locomotive's own + * limits widened by its overage tolerance (global rule caps do not apply, same + * as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here + * showed staff a ceiling the batch engine did not use. */ private computeBoardCapacity( items: Array<{ @@ -1058,29 +1056,18 @@ export class BookingBatchService implements OnModuleInit { }>, loco: Locomotive | null, maxWagons: number | null, - rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); const caps = loco - ? trainHardCaps( - { - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - overageToleranceTons: Number(loco.overageToleranceTons) || 0, - overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, - }, - { - maxTrainWeightTons: rules?.maxTrainWeightTons - ? Number(rules.maxTrainWeightTons) - : undefined, - maxTrainLengthMeters: rules?.maxTrainLengthMeters - ? Number(rules.maxTrainLengthMeters) - : undefined, - }, - ) + ? trainHardCaps({ + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + overageToleranceTons: Number(loco.overageToleranceTons) || 0, + overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, + }) : null; const round2 = (value: number) => Math.round(value * 100) / 100; @@ -1099,7 +1086,6 @@ export class BookingBatchService implements OnModuleInit { private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], - rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule { const loco = s.trainSet?.locomotive ?? null; @@ -1134,7 +1120,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1203,10 +1189,9 @@ export class BookingBatchService implements OnModuleInit { return 0; } - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive, rules); - await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const limits = await this.capacityLimits(locomotive); + await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); const minPerWagon = this.minPerWagonNeed(wagonDims); if (budget.isExhausted(minPerWagon)) { @@ -1405,7 +1390,6 @@ export class BookingBatchService implements OnModuleInit { return { scheduleIds: [], commercialReserved: 0 }; } - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); // Live per-schedule corridor budget + arm flag, in departure order. @@ -1420,8 +1404,8 @@ export class BookingBatchService implements OnModuleInit { ); continue; } - const limits = await this.capacityLimits(locomotive, rules); - await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const limits = await this.capacityLimits(locomotive); + await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, armed: false }); } @@ -1973,9 +1957,8 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) return null; - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive, rules); + const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } @@ -2520,11 +2503,12 @@ export class BookingBatchService implements OnModuleInit { * `base` via {@link needFor}, whose weight axis is gross. The locomotive's * overage tolerance is returned separately — the corridor budget spends it * only to admit a booking whole, never to size a split. + * + * Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length + * caps deliberately do not apply here (a mis-set global row once capped + * every train at 14m and no export booking could board). */ - private async capacityLimits( - locomotive: Locomotive, - rules: TrainSchedulingGlobalRules | null, - ): Promise { + private async capacityLimits(locomotive: Locomotive): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { @@ -2534,14 +2518,6 @@ export class BookingBatchService implements OnModuleInit { overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, - { - maxTrainWeightTons: rules?.maxTrainWeightTons - ? Number(rules.maxTrainWeightTons) - : undefined, - maxTrainLengthMeters: rules?.maxTrainLengthMeters - ? Number(rules.maxTrainLengthMeters) - : undefined, - }, ); return { base: { @@ -2556,18 +2532,23 @@ export class BookingBatchService implements OnModuleInit { }; } - /** Keep schedule.max_wagons aligned with locomotive physical limits. */ + /** + * Keep schedule.max_wagons aligned with the train's boarding limit: the + * locomotive's length-derived slot count. The physical wagons currently in + * the train set do NOT cap this — bookings are admitted on length/weight + * alone and yard staff attach the wagons manually before departure. + */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, - rules: TrainSchedulingGlobalRules | null, ): Promise { - const limits = await this.capacityLimits(locomotive, rules); - if ((schedule.maxWagons ?? 0) !== limits.base.wagons) { + const limits = await this.capacityLimits(locomotive); + const maxWagons = limits.base.wagons; + if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) - .update(schedule.id, { maxWagons: limits.base.wagons }); - schedule.maxWagons = limits.base.wagons; + .update(schedule.id, { maxWagons }); + schedule.maxWagons = maxWagons; } } @@ -2655,12 +2636,6 @@ export class BookingBatchService implements OnModuleInit { }; } - private async loadGlobalRules(): Promise { - return this.dataSource - .getRepository(TrainSchedulingGlobalRules) - .findOne({ where: {} }); - } - /** * Ordered stop yards of the schedule's route (origin → milestones → * destination); the legacy two-stop pseudo-route when milestones are absent. @@ -2684,6 +2659,11 @@ export class BookingBatchService implements OnModuleInit { * Remaining capacity per corridor edge = hard caps minus what allocated + * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. + * + * The wagon axis is the locomotive's length-derived slot count only — the + * physical wagons currently marshalled in the train set do NOT cap it. + * Bookings are admitted on length/weight capacity and yard staff attach + * the missing wagons manually before wagon assignment. */ private async remainingBudget( schedule: TrainSchedule, @@ -2795,9 +2775,8 @@ export class BookingBatchService implements OnModuleInit { if ((await this.remainingWagons(schedule)) <= 0) return true; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive, rules); + const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); return budget.isExhausted(this.minPerWagonNeed(wagonDims)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 48985bdf0..c189825fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { NotificationAudience, + NotificationPriority, NotificationType, NotifyInput, } from '@edr/types'; @@ -114,11 +115,15 @@ export class BookingNotifierService { const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + - `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + - `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` + + `The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` + + `If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + // HIGH: a split is a change to what the customer ordered AND a live payment + // deadline — it must reach email/SMS, not just the portal inbox. this.inApp(b, 'Partial allocation offer', msg, { type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index f53af2a93..eee880a6b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2645,8 +2645,9 @@ export class TrainSchedulingService { const schedules = await this.trainSchedulesRepository.findAll({ relations: { trainSet: { locomotive: true, locomotives: { locomotive: true } }, - // Yards carry the route's display name used by mapScheduleListItem. - route: { originYard: true, destinationYard: true }, + // Yards carry the route's display name used by mapScheduleListItem; + // milestones (with yards) let it show the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, originStation: true, destinationStation: true, scheduleBookings: { booking: true }, @@ -3111,6 +3112,10 @@ export class TrainSchedulingService { const wagonTypes = await this.loadSchedulingWagonTypeDimensions(); if (locomotive) { + // With a locomotive assigned its own limits are the single source of + // truth — global-rules / env caps do not floor them (a mis-set global + // row once capped every train at 14m). Only an explicit per-request dto + // override still applies. const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: Number(locomotive.maxPullWeightTons), @@ -3120,8 +3125,8 @@ export class TrainSchedulingService { }, wagonTypes, { - maxTrainWeightTons: ruleWeightCap, - maxTrainLengthMeters: ruleLengthCap, + maxTrainWeightTons: dto?.maxTrainWeightTons, + maxTrainLengthMeters: dto?.maxTrainLengthMeters, }, ); return { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index c81550cd0..3c9c6c85f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,6 +6,11 @@ export class LoadInventoryDto { @IsUUID() wagonId!: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Train schedule this load belongs to (recorded on the loading).' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' }) @IsOptional() @IsNumber() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts index 5f6952aec..1698b8a5e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts @@ -28,9 +28,17 @@ export class WarehouseLoading extends BaseEntity { @JoinColumn({ name: 'booking_id' }) booking?: Booking | null; - /** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */ - @Column({ name: 'wagon_id', type: 'uuid' }) - wagonId!: string; + /** + * Physical wagon the item was loaded onto. References freight.wagons + * (read-only link). Nullable: a schedule-level auto-load may not resolve to + * one wagon — the train association then lives in trainScheduleId. + */ + @Column({ name: 'wagon_id', type: 'uuid', nullable: true }) + wagonId?: string | null; + + /** Train schedule the item was loaded onto (read-only link to scheduling). */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; @Column({ name: 'loaded_at', type: 'timestamptz' }) loadedAt!: Date; 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 242cecc76..070a267a4 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 @@ -77,11 +77,6 @@ export class WarehouseInventoryController { return this.inventoryService.bulkReceive(dto); } - @Post('load-passed-export') - @ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' }) - loadPassedExport(@Body('performedBy') performedBy?: string) { - return this.inventoryService.loadPassedExport(performedBy); - } @Get('ready-to-load-export') @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) 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 2b98856d0..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 @@ -207,6 +207,7 @@ export interface EligibleBookingRow { customerTin: string | null; customerPhone: string | null; containerNumber: string | null; + sealNumbers: string | null; containerQuantity: number | null; containerPackagingType: string | null; cargoDescription: string | null; @@ -242,11 +243,6 @@ export interface BulkReceiveResult { results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; } -export interface LoadPassedExportResult { - loadedCount: number; - skippedCount: number; - results: { inventoryId: string; status: string; reason?: string }[]; -} export interface BulkInspectResult { inspectedCount: number; @@ -774,7 +770,8 @@ export class WarehouseInventoryService { company.name AS "customer", company.tin AS "customerTin", COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", - bc.container_numbers AS "containerNumber", + COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", + bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL @@ -831,6 +828,14 @@ export class WarehouseInventoryService { LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL ) bc ON true + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers, + string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers + FROM freight.booking_container_units unit + JOIN freight.booking_container line + ON line.id = unit.booking_container_id AND line.deleted_at IS NULL + WHERE line.booking_id = b.id AND unit.deleted_at IS NULL + ) bcu ON true LEFT JOIN LATERAL ( SELECT first_mile.id, first_mile.status, first_mile.vehicle_id FROM freight.first_mile first_mile @@ -881,11 +886,18 @@ export class WarehouseInventoryService { }> = []; await this.dataSource.transaction(async (manager) => { - await this.validateLocation(manager, { + const { warehouse, yard, zone } = await this.validateLocation(manager, { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, }); + // The receive location is whatever the operator selected above — never a + // hand-typed string. Stamp it on the truck entrance for the GRN/notes. + if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) { + dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] + .filter(Boolean) + .join(' / '); + } for (const bookingId of dto.bookingIds) { const skip = (reason: string) => { @@ -1081,46 +1093,6 @@ export class WarehouseInventoryService { return result; } - /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ - async loadPassedExport(performedBy?: string): Promise { - const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); - const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] }; - - for (const item of ready) { - const skip = (reason: string) => { - result.skippedCount += 1; - result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason }); - }; - - if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; } - const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; - if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } - const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; - if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; } - - await this.dataSource.transaction(async (manager) => { - await manager.getRepository(WarehouseInventory).update(item.id, { - status: 'LOADED', - loadedAt: new Date(), - }); - await this.activityLog.record( - { - activityType: 'INVENTORY_LOADED', - inventoryId: item.id, - warehouseId: item.warehouseId, - description: 'Bulk loaded (passed export)', - performedBy, - }, - manager, - ); - }); - - result.loadedCount += 1; - result.results.push({ inventoryId: item.id, status: 'LOADED' }); - } - - return result; - } /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */ private async exportInventoryByStatus( @@ -1302,6 +1274,29 @@ export class WarehouseInventoryService { performedBy?: string, ): Promise { const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; + const [schedule]: Array<{ + trainNumber: string | null; + origin: string | null; + destination: string | null; + departure: string | null; + }> = await this.dataSource.query( + `SELECT ts.train_number AS "trainNumber", + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", + ts.scheduled_departure_date AS "departure" + 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.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + const trainNote = schedule + ? `Loaded onto train ${schedule.trainNumber ?? scheduleId.slice(0, 8)}` + + (schedule.origin || schedule.destination + ? ` (${schedule.origin ?? '?'} -> ${schedule.destination ?? '?'})` + : '') + + (schedule.departure ? `, departure ${new Date(schedule.departure).toISOString()}` : '') + : undefined; const items = await this.trainLoadableItems(scheduleId); const byId = new Map(items.map((i) => [i.id, i])); const affectedBookingIds = new Set(); @@ -1318,7 +1313,12 @@ export class WarehouseInventoryService { if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { - await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy }); + await this.load(inventoryId, { + wagonId: item.wagonId, + loadedBy: performedBy, + trainScheduleId: scheduleId, + notes: trainNote, + }); result.loadedCount += 1; result.results.push({ inventoryId, status: 'LOADED' }); if (item.bookingId) affectedBookingIds.add(item.bookingId); @@ -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], ); @@ -3386,6 +3390,8 @@ export class WarehouseInventoryService { warehouseInventoryId: id, bookingId: item.bookingId ?? null, wagonId: dto.wagonId, + // Which train this load belongs to — durable even if wagons reshuffle. + trainScheduleId: dto.trainScheduleId ?? null, loadedAt: now, loadedBy: dto.loadedBy ?? null, loadedWeight, @@ -3424,7 +3430,7 @@ export class WarehouseInventoryService { }); // Enrich with wagon numbers (read-only lookup into the scheduling domain). - const wagonIds = [...new Set(loadings.map((l) => l.wagonId))]; + const wagonIds = [...new Set(loadings.map((l) => l.wagonId).filter((id): id is string => Boolean(id)))]; const wagonNumbers = new Map(); if (wagonIds.length > 0) { const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query( @@ -3435,7 +3441,7 @@ export class WarehouseInventoryService { } return loadings.map((loading) => - Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }), + Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }), ); } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 99dae5974..13759ddb3 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -18,6 +18,28 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; +/** fileKey of the delegation letter attached to the Power of Attorney step. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** + * Seeded as optional: the delegation letter is only mandatory once a PoA has + * been entered, or when the company operates as a freight forwarder. That rule + * spans form fields as well as files, so it lives in the onboarding gate + * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + */ +const poaDelegationField = (displayOrder: number): OnboardingField => ({ + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: "PoA Delegation Letter", + helpText: + "Signed letter in which the General Manager delegates the representative named above.", + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder, +}); + /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ { @@ -54,6 +76,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, + poaDelegationField(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -102,6 +125,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, + poaDelegationField(5), ]; /** Legacy combined set, kept for the older per-company-type codes. */ diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index d312013aa..2f2e0d7be 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,10 +4,10 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 3000 --clearScreen false", + "dev": "vite --port 5183 --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", - "preview": "vite preview --port 3000", + "preview": "vite preview --port 5183", "lint": "eslint src", "test": "vitest run", "type-check": "tsc -b" diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 598f5e223..ca0fda7a3 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -145,7 +145,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Staff", - href: "/um", + href: "/user-management", icon: , }, { @@ -197,12 +197,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.createBooking, }, - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 33254d3ce..32ec2c511 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -38,6 +38,7 @@ import { MapPin, Package, Receipt, + Repeat, X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -192,9 +193,19 @@ export default function GlCreateBookingForm() { const [notes, setNotes] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); + const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); const seededRef = useRef(false); + const returnSeededRef = useRef(false); + + // Seed the equipment-return toggle from the contract exactly once (also when + // the form is prefilled from a shipment request); GL can flip it per shipment. + useEffect(() => { + if (!contract || returnSeededRef.current) return; + returnSeededRef.current = true; + setWithReturn(contract.equipmentReturn === "WITH_RETURN"); + }, [contract]); const isContainer = contract?.freightType === "CONTAINER"; const routes = useMemo( @@ -527,6 +538,10 @@ export default function GlCreateBookingForm() { scheduledDate, ...(contractRouteId ? { contractRouteId } : {}), ...(notes.trim() ? { notes: notes.trim() } : {}), + // Equipment return is a container concern — bulk keeps the contract default. + ...(isContainer + ? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" } + : {}), }; if (isContainer) { @@ -1091,6 +1106,67 @@ export default function GlCreateBookingForm() { )} + {isContainer ? ( + + } + title="Equipment Return" + description="Choose whether the empty container(s) come back to EDR after unloading." + /> + setWithReturn((v) => !v)} + > + + + + + + + + With return + + + {withReturn + ? "Container(s) returned to EDR after unloading." + : "Container(s) retained by the customer after delivery."} + + + + setWithReturn(e.currentTarget.checked)} + onClick={(e) => e.stopPropagation()} + style={{ flexShrink: 0 }} + /> + + + + ) : null} + } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx index 0ec7ad59f..bf761b748 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx @@ -8,6 +8,12 @@ export interface ActionShellProps { subtitle?: string; /** When true the action is already done — children are hidden, a done badge shows. */ done?: boolean; + /** + * Keep the input controls mounted alongside the done badge. For actions whose + * value stays correctable after completion (e.g. customs risk), rather than + * the default one-and-done actions. + */ + keepChildrenWhenDone?: boolean; doneLabel?: ReactNode; children: ReactNode; } @@ -22,6 +28,7 @@ export function ActionShell({ title, subtitle, done, + keepChildrenWhenDone, doneLabel, children, }: ActionShellProps) { @@ -64,7 +71,7 @@ export function ActionShell({ ) ) : null} - {!done ? children : null} + {!done || keepChildrenWhenDone ? children : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx index 05fd97084..0f655edc5 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core"; import { ShieldAlert } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -15,22 +15,42 @@ const RISK_COLOR: Record = { export function AssignRiskCard({ bookingId, milestone, + locked = false, }: { bookingId: string; milestone: Freight.IClearanceMilestone; + /** + * Duty has already been advised off this risk level, so the decision is now + * final. Until then a mis-assigned level must stay correctable — the server + * accepts reassignment and overwrites the milestone metadata. + */ + locked?: boolean; }) { const assign = useAssignRisk(bookingId); - const [level, setLevel] = useState("GREEN"); const assigned = milestone.status === "COMPLETED"; const current = milestone.metadata?.riskLevel; + const [level, setLevel] = useState( + current ?? "GREEN", + ); + + // The milestone loads (and refetches after a reassignment) after first render, + // so mirror the persisted level onto the control whenever it changes. + useEffect(() => { + if (current) setLevel(current); + }, [current]); return ( @@ -60,9 +80,10 @@ export function AssignRiskCard({ size="compact-sm" color="edr-green" loading={assign.isPending} + disabled={assigned && level === current} onClick={() => assign.mutate({ riskLevel: level })} > - Assign risk + {assigned ? "Reassign risk" : "Assign risk"} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx index 57133cb0c..f11619b82 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx @@ -70,7 +70,11 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) { {showTransport ? : null} {riskMs ? ( - + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 924d62436..371a83ccf 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -153,6 +153,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { : ([] as string[]); const docCount = pending?.documentFileIds?.length ?? 0; const licenseChanges = pending?.licenseChanges ?? []; + const documentChanges = pending?.documentChanges ?? []; const confirmReject = () => { if (!rejectId) return; @@ -210,11 +211,75 @@ export function ChangeRequestReview({ company }: { company: Company }) { )} + {documentChanges.length > 0 && ( + + + Document changes + + {documentChanges.map((c, i) => ( + + {c.op === "add" ? ( + + ) : ( + + )} + + {c.op === "add" ? "Add" : "Remove"} + + + view({ + name: c.fileName ?? humanize(c.code), + url: fileViewUrl(c.fileId), + }) + } + style={{ + textDecoration: + c.op === "remove" ? "line-through" : undefined, + }} + > + {c.fileName ?? humanize(c.code)} + + + {humanize(c.code)} + + + ))} + + )} + {docCount > 0 && ( - - {docCount} document{docCount === 1 ? "" : "s"} uploaded with this - request — review them in the Documents tab. - + + + Documents uploaded with this request + + {pending!.documentFileIds.map((fileId, i) => ( + + + + view({ + name: `Document ${i + 1}`, + url: fileViewUrl(fileId), + }) + } + > + Document {i + 1} + + + ))} + )} {licenseChanges.length > 0 && ( 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 a1b91decb..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'; @@ -150,9 +152,6 @@ interface TruckEntranceFormState { assignedEquipmentNumber: string; customsSealNumber: string; declarationNumber: string; - incoterms: string; - hsCodes: string; - itemCode: string; itemDescription: string; packagingType: string; unitCount: number | ''; @@ -162,7 +161,6 @@ interface TruckEntranceFormState { volumeDimensions: string; conditionAtReceipt: string; damagedRejectedQuantity: number | ''; - warehouseCodeLocation: string; driverName: string; driverPhone: string; driverLicenseNumber: string; @@ -205,9 +203,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ assignedEquipmentNumber: '', customsSealNumber: '', declarationNumber: '', - incoterms: '', - hsCodes: '', - itemCode: '', itemDescription: '', packagingType: '', unitCount: '', @@ -217,7 +212,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ volumeDimensions: '', conditionAtReceipt: '', damagedRejectedQuantity: '', - warehouseCodeLocation: '', driverName: '', driverPhone: '', driverLicenseNumber: '', @@ -239,9 +233,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined, - incoterms: form.incoterms.trim() || undefined, - hsCodes: form.hsCodes.trim() || undefined, - itemCode: form.itemCode.trim() || undefined, itemDescription: form.itemDescription.trim() || undefined, packagingType: form.packagingType.trim() || undefined, unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), @@ -251,7 +242,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl volumeDimensions: form.volumeDimensions.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity), - warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined, driverName: form.driverName.trim(), driverPhone: form.driverPhone.trim(), driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, @@ -265,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.'} + + ) : ( + + + + Container # + Goods + Stage + Truck + GRN + + + + {items.map((i) => ( + + + {i.containerNumber} + + {i.goods ?? '—'} + + + {i.stage} + + + {i.truckPlate ?? '—'} + {i.grnNumber ?? '—'} + + ))} + +
+ )} +
+
+ ); +} + +type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void }; + +/** One-click bulk actions are irreversible — make the click deliberate. */ +function ConfirmActionModal({ + action, + onClose, +}: { + action: ConfirmAction | null; + onClose: () => void; +}) { + return ( + + + {action?.message} + + + + + + + ); +} + +/** "3 skipped — Booking not PAID" instead of a bare count. */ +const skippedSummary = ( + skippedCount: number, + results: Array<{ reason?: string; message?: string }>, +): string | undefined => { + if (!skippedCount) return undefined; + const reason = results.find((x) => x.reason || x.message); + return `${skippedCount} skipped${reason ? ` — ${reason.reason ?? reason.message}` : ''}`; +}; + const commonNonEmptyValue = (values: Array) => { const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; return unique.length === 1 ? unique[0] : ''; @@ -296,6 +407,10 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { const truckType = commonNonEmptyValue( bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType), ); + const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers)); + // Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed. + const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN; + const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : ''; const edrDigitalBookingId = bookings.length === 1 ? bookings[0]?.reference ?? bookings[0]?.id ?? '' @@ -321,9 +436,11 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { customerPhone, edrDigitalBookingId, assignedEquipmentNumber, + customsSealNumber, itemDescription, packagingType, unitCount, + netWeightKg, grossWeightKg: '', truckPlateNumber, trailerPlateNumber, @@ -331,6 +448,7 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { driverPhone, driverLicenseNumber, truckType, + driverSignatoryName: driverName, }, lockedFields: { ownerName: Boolean(ownerName), @@ -550,38 +668,19 @@ function TruckEntranceFields({ )} Customs and compliance - - onChange({ ...value, declarationNumber: e.currentTarget.value })} - /> - onChange({ ...value, incoterms: e.currentTarget.value })} - /> - onChange({ ...value, hsCodes: e.currentTarget.value })} + label="Declaration / Bill of Entry number" + value={value.declarationNumber} + onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })} /> Physical cargo specifications - - onChange({ ...value, itemCode: e.currentTarget.value })} - /> - onChange({ ...value, itemDescription: e.currentTarget.value })} - /> - + onChange({ ...value, itemDescription: e.currentTarget.value })} + /> ({ + value: t.scheduleId, + label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'} → ${t.destination ?? '?'} · dep ${t.departureTime ? formatDate(t.departureTime) : '—'} · ${t.readyCount} ready`, + }))} + value={targetScheduleId} + onChange={setTargetScheduleId} + searchable + /> + )} + + + + + + + {isLoading ? ( @@ -1498,22 +1666,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: No EXPORT items with inspection PASSED waiting to be loaded. ) : ( - + - - - + Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1525,29 +1684,24 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: {rows.map((r: ReadyToLoadRow) => ( - + + - toggleOne(r.id)} - /> + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1561,11 +1715,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: - - {r.status} - + + {expandedRow === r.id && ( + + )} + ))}
@@ -1592,6 +1752,8 @@ function LoadedExportTab({ const { data: rows = [], isLoading } = useQuery( api.warehouses.loadedExport.queryOptions({ enabled }), ); + const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const bulkDispatch = useMutation( api.warehouses.bulkDispatchExport.mutationOptions(), ); @@ -1616,7 +1778,7 @@ function LoadedExportTab({ const r = await bulkDispatch.mutateAsync(inventoryIds); toast({ title: `${r.dispatchedCount} dispatched`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); onChanged?.(); @@ -1646,7 +1808,14 @@ function LoadedExportTab({ variant="default" disabled={rows.length === 0} loading={bulkDispatch.isPending} - onClick={() => dispatch(rows.map((r) => r.id))} + onClick={() => + setConfirmAction({ + title: 'Dispatch all', + message: `Dispatch all ${rows.length} loaded item(s)? They leave warehouse inventory for the train.`, + confirmLabel: `Dispatch ${rows.length}`, + run: () => dispatch(rows.map((r) => r.id)), + }) + } > Dispatch All @@ -1656,7 +1825,14 @@ function LoadedExportTab({ leftSection={} disabled={selected.size === 0} loading={bulkDispatch.isPending} - onClick={() => dispatch([...selected])} + onClick={() => + setConfirmAction({ + title: 'Dispatch selected', + message: `Dispatch ${selected.size} selected item(s)? They leave warehouse inventory for the train.`, + confirmLabel: `Dispatch ${selected.size}`, + run: () => dispatch([...selected]), + }) + } > Dispatch Selected @@ -1673,7 +1849,7 @@ function LoadedExportTab({ No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}. ) : ( - + @@ -1687,10 +1863,9 @@ function LoadedExportTab({ /> )} + Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1701,7 +1876,8 @@ function LoadedExportTab({ {rows.map((r: ReadyToLoadRow) => ( - + + {dispatchable && ( )} - - {r.bookingReference ?? '—'} - - + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + + + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1734,16 +1911,23 @@ function LoadedExportTab({ {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} - - {r.status} - + + {expandedRow === r.id && ( + + )} + ))}
)} + setConfirmAction(null)} /> ); } @@ -1845,9 +2029,7 @@ function ImportTrainDetailTable({ Wagon - Booking ID Booking Ref - Customer ID Customer Name Container # Cargo Type @@ -1881,15 +2063,9 @@ function ImportTrainDetailTable({ {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} - - {it.bookingId.slice(0, 8)}… - {it.bookingReference ?? '—'} - - {it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'} - {it.customerName ?? '—'} {it.containerNumber ?? '—'} {it.cargoType ?? '—'} @@ -1979,6 +2155,7 @@ function ImportArriveQueueTab({ api.warehouses.autoUnloadArrivedBookings.mutationOptions(), ); const [openId, setOpenId] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); const [busyId, setBusyId] = useState(null); const [assignmentsBySchedule, setAssignmentsBySchedule] = useState< Record> @@ -2020,7 +2197,7 @@ function ImportArriveQueueTab({ const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0; const firstReason = r.results.find((item) => item.reason)?.reason; const extra = [ - r.skippedCount ? `${r.skippedCount} skipped` : '', + skippedSummary(r.skippedCount, r.results) ?? '', r.failedCount ? `${r.failedCount} failed` : '', ] .filter(Boolean) @@ -2116,7 +2293,14 @@ function ImportArriveQueueTab({ leftSection={} loading={busyId === t.scheduleId} disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading} - onClick={() => autoUnload(t)} + onClick={() => + setConfirmAction({ + title: 'Auto unload train', + message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`, + confirmLabel: 'Unload train', + run: () => autoUnload(t), + }) + } > {fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'} @@ -2155,6 +2339,7 @@ function ImportArriveQueueTab({
)} + setConfirmAction(null)} /> ); } @@ -2175,6 +2360,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { ); const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); const [selected, setSelected] = useState>(new Set()); + const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const [inspectId, setInspectId] = useState(null); const [busyId, setBusyId] = useState(null); const [viewItem, setViewItem] = useState(null); @@ -2206,7 +2393,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); @@ -2310,7 +2497,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { leftSection={} disabled={selected.size === 0} loading={inspectMutation.isPending} - onClick={markInspected} + onClick={() => + setConfirmAction({ + title: 'Mark inspected', + message: `Mark ${selected.size} selected item(s) as inspection PASSED? Passed import items become ready for pickup.`, + confirmLabel: `Mark ${selected.size} inspected`, + run: markInspected, + }) + } > Mark Selected as Inspected @@ -2326,10 +2520,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { No unloaded import items. Items appear here after Auto Unload on an arrived train. ) : ( - + + (allSelected ? unselectAll() : selectAll())} /> - Booking ID Booking Ref GRN - Customer ID Customer Name Arrival Time Container # @@ -2357,7 +2550,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {rows.map((r: ImportUnloadedItem) => ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {formatDate(r.arrivalTime)} {r.containerNumber ?? '—'} @@ -2398,7 +2593,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { - {r.currentStatus} + @@ -2492,6 +2687,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { + {expandedRow === r.id && ( + + )} + ))}
@@ -2520,6 +2723,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { bookingId={containerItemsItem?.booking?.id ?? null} bookingReference={containerItemsItem?.booking?.reference ?? null} /> + setConfirmAction(null)} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index d986e2aba..6833167f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -112,7 +112,9 @@ const parseInspectionNote = (notes: string | null | undefined) => { export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); - const bookingId = item?.booking?.id; + // Some openers (inventory workbench) supply bookingId without the booking + // relation — fall back to it, or the truck/container-weight queries never run. + const bookingId = item?.booking?.id ?? item?.bookingId ?? undefined; // Customer self-haul trucks assigned to this booking via the portal. const { data: customerTrucks = [] } = useQuery({ queryKey: ['release-customer-trucks', bookingId], @@ -200,8 +202,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea ]; // Only trucks actually assigned to THIS booking (last-mile prefill or customer // portal) are selectable. No global fleet list — if nothing is assigned, the - // operator types the plate manually in the field below. - const truckSelectOptions = assignedTruckOptions; + // operator types the plate manually in the field below. Deduped by plate: + // duplicate option values crash Mantine's Select. + const truckSelectOptions = [ + ...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(), + ]; // Neither a last-mile truck nor a customer truck has been assigned yet. const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; @@ -214,10 +219,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const containerWeightByNumber = new Map( containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), ); - const containerSelectData = containerWeights.map((c) => ({ - value: c.containerNumber, - label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, - })); + // Mantine Selects throw on duplicate option values — legacy bookings can carry + // the same container number on two lines, so dedupe defensively. + const containerSelectData = [ + ...new Map( + containerWeights.map((c) => [ + c.containerNumber, + { + value: c.containerNumber, + label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, + }, + ]), + ).values(), + ]; const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); const selectedCargoWeight = Number( selectedContainerNumbers diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts b/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts index 766c04ee1..9e58e84bc 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts @@ -9,6 +9,12 @@ export interface ShipmentListRow { summary: string; status: Freight.BookingRequestStatus; createdBookingId?: string | null; + /** When the customer submitted the request — the queue's default sort key. */ + createdAt?: string | null; + customerName?: string | null; + freightKind?: "CONTAINER" | "BULK"; + hazardous?: boolean; + reefer?: boolean; } export type ShipmentRowAction = diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 2c5af46ab..5ed88d5ac 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -236,8 +236,6 @@ export function useEligibleBookings(enabled = true) { } export const useBulkReceive = () => useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); -export const useLoadPassedExport = () => - useInventoryMutation(() => warehouseService.loadPassedExport()); export const useBulkMarkInspected = () => useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload)); diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx index 0ad7ef301..2d635fe58 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx @@ -1,15 +1,18 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { ActionIcon, Badge, + Box, Button, Card, Center, Group, Loader, + Menu, Modal, Paper, + ScrollArea, Stack, Switch, Text, @@ -19,13 +22,29 @@ import { Tooltip, } from "@mantine/core"; import { + AlertTriangle, ArrowDown, ArrowUp, + Banknote, + Building2, + CalendarClock, + CalendarDays, + CalendarRange, + ChevronDown, + Coins, + Hash, + ListOrdered, + ListPlus, + Mail, + MapPin, + Package, Pencil, + Phone, Plus, RefreshCw, Settings2, Trash2, + Weight, } from "lucide-react"; import { PageContainer, PageHeader } from "@/components/page"; @@ -41,7 +60,7 @@ import { import type { ContractTemplateArticle } from "@/services/contract-templates.service"; const BODY_HINT = - 'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.'; + 'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.'; interface ArticleDraft { id?: string; @@ -49,6 +68,254 @@ interface ArticleDraft { body: string; } +interface PlaceholderDef { + token: string; + label: string; + icon: typeof Building2; + hint: string; +} + +/** + * Placeholders the renderer fills from the contract view model + * (contract-view-model.builder.ts). Quick row = the ones template authors + * reach for constantly; the rest live in the grouped "More" menu. + */ +const QUICK_PLACEHOLDERS: PlaceholderDef[] = [ + { + token: "{{client.companyName}}", + label: "Client name", + icon: Building2, + hint: "Company name of the contracting client", + }, + { + token: "{{reference}}", + label: "Reference", + icon: Hash, + hint: "Contract reference number", + }, + { + token: "{{contractDate}}", + label: "Contract date", + icon: CalendarDays, + hint: "Full signature date of the contract", + }, + { + token: "{{contractYear}}", + label: "Contract year", + icon: CalendarRange, + hint: "Year the contract is signed", + }, + { + token: "{{pricing.totalAmount}}", + label: "Total price", + icon: Banknote, + hint: "Total contract price from the pricing schedule", + }, +]; + +const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [ + { + label: "Client", + items: [ + { + token: "{{client.companyAddress}}", + label: "Client address", + icon: MapPin, + hint: "Street address of the client", + }, + { + token: "{{client.companyLocation}}", + label: "Client location", + icon: MapPin, + hint: "Region / city of the client", + }, + { + token: "{{client.phone}}", + label: "Client phone", + icon: Phone, + hint: "Client phone number", + }, + { + token: "{{client.email}}", + label: "Client email", + icon: Mail, + hint: "Client email address", + }, + { + token: "{{client.tinNumber}}", + label: "Client TIN", + icon: Hash, + hint: "Client tax identification number", + }, + ], + }, + { + label: "Route & cargo", + items: [ + { + token: "{{schedule.originLabel}}", + label: "Origin", + icon: MapPin, + hint: "Origin yard / station", + }, + { + token: "{{schedule.destinationLabel}}", + label: "Destination", + icon: MapPin, + hint: "Destination yard / station", + }, + { + token: "{{schedule.serviceType}}", + label: "Service type", + icon: Settings2, + hint: "Contracted service type name", + }, + { + token: "{{schedule.cargoDescription}}", + label: "Cargo description", + icon: Package, + hint: "Description of the cargo", + }, + { + token: "{{schedule.totalWeightVgm}}", + label: "Total weight", + icon: Weight, + hint: "Total verified gross mass", + }, + { + token: "{{schedule.equipmentReturn}}", + label: "Equipment return", + icon: RefreshCw, + hint: "Empty-equipment return terms", + }, + { + token: "{{schedule.scheduledDate}}", + label: "Scheduled date", + icon: CalendarClock, + hint: "Scheduled shipment date", + }, + ], + }, + { + label: "Pricing", + items: [ + { + token: "{{pricing.currency}}", + label: "Currency", + icon: Coins, + hint: "Payment currency (e.g. USD)", + }, + ], + }, + { + label: "Service provider (EDR)", + items: [ + { + token: "{{provider.name}}", + label: "Provider name", + icon: Building2, + hint: "EDR legal company name", + }, + { + token: "{{provider.address}}", + label: "Provider address", + icon: MapPin, + hint: "EDR principal place of business", + }, + { + token: "{{provider.phone}}", + label: "Provider phone", + icon: Phone, + hint: "EDR phone number", + }, + { + token: "{{provider.email}}", + label: "Provider email", + icon: Mail, + hint: "EDR email address", + }, + ], + }, +]; + +const ALL_PLACEHOLDERS: PlaceholderDef[] = [ + ...QUICK_PLACEHOLDERS, + ...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items), +]; + +const KNOWN_TOKENS = new Set(ALL_PLACEHOLDERS.map((p) => p.token)); + +/** Any {{…}} tokens in the text the renderer does not know how to fill. */ +function unknownTokens(text: string): string[] { + const found = text.match(/\{\{[^{}]+\}\}/g) ?? []; + return [...new Set(found.filter((t) => !KNOWN_TOKENS.has(t)))]; +} + +interface ParsedClause { + text: string; + bullets: string[]; +} + +interface ParsedBody { + /** Set (instead of clauses) when the body is one plain paragraph. */ + paragraph?: string; + clauses: ParsedClause[]; +} + +/** + * Mirror of the API renderer's rules (contract-article.util.ts): one clause per + * line, "- " nests a bullet under the previous clause, and a single bullet-less + * clause renders as a plain paragraph instead of a numbered list of one. + */ +function parseArticleBody(body: string): ParsedBody { + const clauses: ParsedClause[] = []; + for (const raw of body.split("\n")) { + const line = raw.trim(); + if (!line) continue; + if (line.startsWith("- ") && clauses.length > 0) { + clauses[clauses.length - 1].bullets.push(line.slice(2).trim()); + } else { + clauses.push({ text: line.replace(/^- /, ""), bullets: [] }); + } + } + if (clauses.length === 1 && clauses[0].bullets.length === 0) { + return { paragraph: clauses[0].text, clauses: [] }; + } + return { clauses }; +} + +/** Render clause text with {{placeholders}} highlighted as green chips. */ +function HighlightedText({ text }: { text: string }) { + const parts = text.split(/(\{\{[^{}]+\}\})/g); + return ( + <> + {parts.map((part, i) => + /^\{\{[^{}]+\}\}$/.test(part) ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} + export default function ContractTemplateEditorPage() { const { code } = useParams<{ code: string }>(); const { data: template, isLoading } = useContractTemplate(code); @@ -79,15 +346,12 @@ export default function ContractTemplateEditorPage() { ); }; - const saveArticle = () => { + const saveArticle = (values: { title: string; body: string }) => { if (!articleDraft) return; if (articleDraft.id) { - updateArticle.mutate({ - articleId: articleDraft.id, - payload: { title: articleDraft.title, body: articleDraft.body }, - }); + updateArticle.mutate({ articleId: articleDraft.id, payload: values }); } else { - addArticle.mutate({ title: articleDraft.title, body: articleDraft.body }); + addArticle.mutate(values); } setArticleDraft(null); }; @@ -264,55 +528,14 @@ export default function ContractTemplateEditorPage() { {/* ── Add / edit article modal ───────────────────────────────────── */} - setArticleDraft(null)} - title={articleDraft?.id ? "Edit article" : "Add article"} - size="xl" - > - {articleDraft && ( - - - setArticleDraft({ ...articleDraft, title: event.currentTarget.value }) - } - required - /> -