From 6d6b32feae1173e8b882fa753850582b608700bd Mon Sep 17 00:00:00 2001 From: hagiye Date: Tue, 1 Sep 2026 14:44:45 +0300 Subject: [PATCH 1/2] api key fix --- .../src/config/eims.config.spec.ts | 14 ++++ .../modules/eims/eims-client.service.spec.ts | 70 +++++++++++++------ .../src/modules/eims/eims-client.service.ts | 24 +++---- .../eims-invoice-registration.service.spec.ts | 44 ++++++------ .../eims/eims-invoice-registration.service.ts | 8 +-- .../modules/eims/eims-receipt.service.spec.ts | 2 + 6 files changed, 99 insertions(+), 63 deletions(-) diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index a6aa3895d..5e89822f4 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -26,6 +26,20 @@ const withEnv = (vars: Record, fn: () => void) => { }; describe("eims.config — private key / certificate resolution", () => { + it("requires EIMS_API_KEY when EIMS is enabled without exposing a value", () => { + withEnv( + { + ...REQUIRED, + EIMS_API_KEY: undefined, + EIMS_PRIVATE_KEY: "private-key-present", + EIMS_CERTIFICATE: "certificate-present", + }, + () => { + expect(() => eimsConfigFactory()).toThrow(/env vars are missing: EIMS_API_KEY/); + }, + ); + }); + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { withEnv( { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts index 4bb20d80e..3d68e180e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts @@ -1,4 +1,5 @@ import { HttpService } from "@nestjs/axios"; +import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { AxiosError, AxiosHeaders } from "axios"; import { of, throwError } from "rxjs"; @@ -46,24 +47,48 @@ const sentConfig = (post: jest.Mock, call = 0) => post.mock.calls[call][2]; const sentBody = (post: jest.Mock, call = 0) => post.mock.calls[call][1]; describe("EimsClientService transport", () => { - it("bearer-authenticates every protected call", async () => { - const post = ok(); - await build(post).postBearer("/v1/cancel", { Irn: "irn-1" }); + const protectedHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${TOKEN}`, + apikey: API_KEY, + }; - expect(sentConfig(post).headers).toEqual({ - "Content-Type": "application/json", - Authorization: `Bearer ${TOKEN}`, + it.each([ + ["verify", "/v1/verify", { irn: "irn-1" }], + ["sales receipt", "/v1/receipt/sales", { receipt: "sales" }], + ["withholding receipt", "/v1/receipt/withholding", { receipt: "withholding" }], + ["cancel", "/v1/cancel", { Irn: "irn-1" }], + ["bulk cancel", "/v1/bulkCancel", [{ Irn: "irn-1" }]], + ])("authenticates the raw %s endpoint without changing its body", async (_name, path, body) => { + const post = ok(); + await build(post).postBearer(path, body); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + expect(sentBody(post)).toBe(body); + }); + + it.each([ + ["invoice", { DocumentDetails: { Type: "INV" } }], + ["credit memo", { DocumentDetails: { Type: "CRE" } }], + ["debit memo", { DocumentDetails: { Type: "DEB" } }], + ])("authenticates and signs a %s registration", async (_name, request) => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", request); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + expect(JSON.parse(sentBody(post) as string)).toEqual({ + request, + signature: "SIGNATURE", + certificate: "CERTIFICATE", }); }); - it("sends the same headers on a signed call", async () => { - const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); - await build(post).postSigned("/v1/register", { Invoice: 1 }); + it("authenticates bulk registration through the same signed path", async () => { + const post = ok({ conversationId: "conversation-1", status: 202 }); + const request = [{ DocumentDetails: { Type: "INV" } }]; + await build(post).postSigned("/v1/bulkRegister", request); - expect(sentConfig(post).headers).toEqual({ - "Content-Type": "application/json", - Authorization: `Bearer ${TOKEN}`, - }); + expect(sentConfig(post).headers).toEqual(protectedHeaders); }); it("wraps a signed call in the {request,signature,certificate} envelope", async () => { @@ -85,26 +110,24 @@ describe("EimsClientService transport", () => { expect(sentBody(post)).toEqual({ Irn: "irn-1" }); }); - it("re-signs and re-authenticates through the one 401 retry", async () => { + it("re-authenticates a raw verify call through the one 401 retry without changing its body", async () => { const post = jest .fn() .mockReturnValueOnce(throwError(() => axiosErr(401, { message: "expired" }))) .mockReturnValueOnce(of({ data: { statusCode: 200, body: { Irn: "irn-1" } } })); - await build(post).postSigned("/v1/verify", { irn: "irn-1" }); + await build(post).postBearer("/v1/verify", { irn: "irn-1" }); expect(post).toHaveBeenCalledTimes(2); expect(sentConfig(post, 1).headers.Authorization).toBe(`Bearer ${TOKEN}`); - expect(JSON.parse(sentBody(post, 1) as string)).toMatchObject({ - request: { irn: "irn-1" }, - signature: "SIGNATURE", - }); + expect(sentBody(post, 1)).toEqual({ irn: "irn-1" }); }); it("never leaks the api key, bearer token or client secret into a thrown failure", async () => { + const logError = jest.spyOn(Logger.prototype, "error").mockImplementation(() => undefined); const post = jest.fn().mockReturnValue( throwError(() => - // The live shape of an unsigned /v1/verify rejection, as observed on 2026-09-01. + // A gateway rejection may echo request data; redaction must remove it before logging. axiosErr(400, { message: "GATEWAY ERROR", code: "4001", @@ -120,7 +143,7 @@ describe("EimsClientService transport", () => { ); const error: Error = await build(post) - .postSigned("/v1/verify", { irn: "irn-1" }) + .postBearer("/v1/verify", { irn: "irn-1" }) .then(() => { throw new Error("expected the call to reject"); }) @@ -134,7 +157,12 @@ describe("EimsClientService transport", () => { expect(serialized).not.toContain(API_KEY); expect(serialized).not.toContain(CLIENT_SECRET); expect(serialized).not.toContain(TOKEN); + const serializedLogs = JSON.stringify(logError.mock.calls); + expect(serializedLogs).not.toContain(API_KEY); + expect(serializedLogs).not.toContain(CLIENT_SECRET); + expect(serializedLogs).not.toContain(TOKEN); // The gateway's own reporting still survives redaction. expect(error.message).toContain("4001"); + logError.mockRestore(); }); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts index 1870814c5..4f2455c85 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -8,7 +8,7 @@ import { EimsSignerService, toSignedBody } from "./eims-signer.service"; import { toEimsApiException } from "./eims.errors"; /** - * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). + * Foundation for EIMS's authenticated endpoints (`/v1/register`, `/v1/verify`, …). * * Login is not routed through here: `/auth/login` carries no bearer token and lives in * `EimsAuthService`. @@ -29,7 +29,8 @@ export class EimsClientService { } /** - * Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response. + * Sign `request`, POST it to `path` with the shared protected-endpoint headers, and return the + * parsed response. * A 401 invalidates the cached token and retries exactly once. */ async postSigned(path: string, request: TRequest): Promise { @@ -37,17 +38,8 @@ export class EimsClientService { } /** - * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. - * - * The supplied collection sends raw bodies for `/v1/verify`, `/v1/cancel` and the receipt - * endpoints. That turned out to be wrong for `/v1/verify`, which the live gateway rejects with - * `GATEWAY ERROR code=4001` (`request`/`signature`/`certificate` must not be null) until the - * envelope is added — so verify now uses `postSigned`. - * - * The remaining callers (cancel, bulk cancel, receipts) still send raw bodies and have **not** - * been exercised against the live gateway. Each is a candidate for the same rejection; none can - * be probed safely, because unlike verify they all mutate state at MoR. Expect to convert them - * the same way the first time one is filed for real. + * POST `request` verbatim with the shared protected-endpoint headers, but **not** wrapped in a + * signed envelope. This is the wire contract for verify, cancel and receipt calls. */ async postBearer(path: string, request: TRequest): Promise { return this.send(path, request, false, false); @@ -66,7 +58,11 @@ export class EimsClientService { try { const res = await firstValueFrom( this.http.post(`${cfg.baseUrl}${path}`, body, { - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + apikey: cfg.apiKey, + }, timeout: cfg.httpTimeoutMs, }), ); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 8f8a94a7c..34e654e3b 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -759,39 +759,37 @@ describe("EimsInvoiceRegistrationService staff alerting", () => { }); describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { - it("verifies the stored IRN over the signed transport", async () => { + it("verifies the stored IRN as an unchanged raw body", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postBearer = jest.fn(); - const postSigned = jest.fn().mockResolvedValue(verifyResponse()); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn(); const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( INVOICE_ID, ); - // Lowercase `irn`, signed envelope — the live gateway rejects the unsigned body with - // `code=4001` naming request/signature/certificate as null. - expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); - expect(postBearer).not.toHaveBeenCalled(); + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postSigned).not.toHaveBeenCalled(); expect(result.body).toMatchObject({ Irn: IRN }); }); it("rejects a 200 that carries no Irn", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } }); await expect( - build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/returned no Irn/); }); it("refuses to verify an invoice with no IRN", async () => { const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]); - const postSigned = jest.fn(); + const postBearer = jest.fn(); await expect( - build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/no EIMS IRN to verify/); - expect(postSigned).not.toHaveBeenCalled(); + expect(postBearer).not.toHaveBeenCalled(); }); it("leaves a filed invoice and the IRN chain untouched when the gateway rejects the verify", async () => { @@ -803,12 +801,12 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { // The live failure this guards: `GATEWAY ERROR code=4001`, a transport fault on a document // that is already registered. Verification is a read — a failed read must never downgrade the // registration or move the counter. - const postSigned = jest + const postBearer = jest .fn() .mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "GATEWAY ERROR code=4001", 400)); await expect( - build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID), + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), ).rejects.toThrow(/4001/); expect(db.invoices.get(INVOICE_ID)).toEqual(before); @@ -835,15 +833,15 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("records a confirmed IRN, resumes the chain and clears the block", async () => { const db = blocked(); - const postSigned = jest.fn().mockResolvedValue(verifyResponse()); + const postBearer = jest.fn().mockResolvedValue(verifyResponse()); - const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration( + const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( INVOICE_ID, { irn: IRN }, ); // The IRN is confirmed at the gateway before it is ever written. - expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); + expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN }); expect(db.state).toMatchObject({ previousIrn: IRN, @@ -854,12 +852,12 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => { const db = blocked(); - const postSigned = jest + const postBearer = jest .fn() .mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" })); await expect( - build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/answered the lookup for IRN/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ @@ -875,14 +873,14 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => { const db = blocked(); - const postSigned = jest.fn().mockResolvedValue( + const postBearer = jest.fn().mockResolvedValue( verifyResponse({ DocumentDetails: { Type: "INV", DocumentNumber: "99999" }, }), ); await expect( - build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/not 5/); expect(db.invoices.get(INVOICE_ID)).toMatchObject({ @@ -898,10 +896,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("refuses an IRN the gateway does not acknowledge at all", async () => { const db = blocked(); - const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); + const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} }); await expect( - build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), + build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }), ).rejects.toThrow(/returned no Irn/); expect(db.state!.blockedReason).toBe("never acknowledged"); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 65221d48c..246914e1c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -214,13 +214,11 @@ export class EimsInvoiceRegistrationService { * compared — the supplied collection's own fixture uses different example values on each side, * so equality there would assert a property of the mock rather than of the gateway. * - * Signed, via `postSigned`. The supplied collection shows a raw `{"irn":"…"}` body, but the live - * gateway rejects that with `GATEWAY ERROR code=4001` naming `request`, `signature` and - * `certificate` as null — verified against `core.mor.gov.et` on 2026-09-01. The signed envelope - * clears that validation. The collection's unsigned example is wrong for this endpoint. + * Raw, via `postBearer`: verification accepts exactly `{"irn":"…"}` and relies on the shared + * transport for the bearer token and API-key header. It must not be signed or wrapped. */ private async queryVerify(irn: string): Promise { - const response = await this.client.postSigned( + const response = await this.client.postBearer( "/v1/verify", { irn }, ); diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts index 55a38480b..99af6ef30 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts @@ -191,6 +191,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => { it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => { const db = new FakeDb([invoiceRow()]); + const invoiceBefore = { ...db.invoices.get(INVOICE_ID)! }; const postBearer = jest .fn() .mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406)); @@ -200,6 +201,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => { ).rejects.toBeInstanceOf(EimsApiException); const [receipt] = [...db.receipts.values()]; expect(receipt.status).toBe(EimsReceiptStatus.Failed); + expect(db.invoices.get(INVOICE_ID)).toEqual(invoiceBefore); }); it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => { From 031efce92b343252d284e96c525a6d55586a5f51 Mon Sep 17 00:00:00 2001 From: hagiye Date: Wed, 2 Sep 2026 01:41:08 +0300 Subject: [PATCH 2/2] Truck assiggnment and per truc --- .../src/common/truck-load.util.spec.ts | 10 + .../src/common/truck-load.util.ts | 7 +- .../src/modules/bookings/bookings.service.ts | 39 +- .../bookings/customer-truck.service.ts | 37 +- .../warehouses/dto/bulk-receive.dto.ts | 31 +- .../warehouses/warehouse-inventory.service.ts | 380 +++++++++++++++--- .../warehouses/ReceiveInventoryModal.tsx | 302 +++++++++++++- .../warehouses/ReleaseOrderModal.tsx | 28 +- .../src/services/warehouse.service.ts | 2 +- .../backoffice/src/types/warehouse.ts | 21 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 2 +- .../CustomerTruckAssignmentCard.tsx | 7 + 12 files changed, 769 insertions(+), 97 deletions(-) diff --git a/apps/edr-freight-api/src/common/truck-load.util.spec.ts b/apps/edr-freight-api/src/common/truck-load.util.spec.ts index fbb3a436a..de5ec9550 100644 --- a/apps/edr-freight-api/src/common/truck-load.util.spec.ts +++ b/apps/edr-freight-api/src/common/truck-load.util.spec.ts @@ -45,6 +45,16 @@ describe('assertTruckLoad', () => { ).toThrow(BadRequestException); }); + it('allows two containers only when both are explicitly 20ft', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['20ft', '45ft'], + }), + ).toThrow(BadRequestException); + }); + it('rejects more than two containers', () => { expect(() => assertTruckLoad({ diff --git a/apps/edr-freight-api/src/common/truck-load.util.ts b/apps/edr-freight-api/src/common/truck-load.util.ts index b65bc12fb..6380786bd 100644 --- a/apps/edr-freight-api/src/common/truck-load.util.ts +++ b/apps/edr-freight-api/src/common/truck-load.util.ts @@ -54,10 +54,11 @@ export function assertTruckLoad({ } } - // A 40ft fills the bed, so it travels alone. - if (containers.length > 1 && sizes.some((size) => size.includes('40'))) { + // A truck may pair containers only when BOTH are explicitly 20ft. A 40ft + // (and any legacy/unknown larger size) fills the bed and travels alone. + if (containers.length > 1 && sizes.some((size) => !size.includes('20'))) { throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', + 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers', ); } } 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 da7811ecb..9b213d675 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -312,12 +312,27 @@ export class BookingsService { LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + AND ( + $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS ( + SELECT 1 + FROM freight.booking_container_units received_unit + JOIN freight.booking_container received_line + ON received_line.id = received_unit.booking_container_id + AND received_line.deleted_at IS NULL + WHERE received_line.booking_id = a.booking_id + AND received_unit.container_number = ci.container_number + AND received_unit.received_to_port = true + AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL + AND received_unit.deleted_at IS NULL + ) + ) WHERE a.booking_id = $1 AND a.deleted_at IS NULL GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, s.train_number, s.scheduled_departure_date, so.label, sd.label, by_.label, ay.label + HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0 ORDER BY tsw.sequence_no`, - [bookingId], + [bookingId, booking.tradeDirection, booking.freightType], ); // Export acceptance happens at the warehouse gate, not at marshalling: EDR // takes custody of the cargo when it receives it, and the customer is handed @@ -352,17 +367,17 @@ export class BookingsService { ) : booking.tradeDirection === 'EXPORT' ? await this.dataSource.query( - `SELECT inv.weight AS "allocatedWeightTons", - c.container_number AS "containerNumbers" - FROM freight.warehouse_inventory inv - LEFT JOIN freight.containers c - ON c.id = inv.container_id AND c.deleted_at IS NULL - WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL - AND COALESCE( - NULLIF(TRIM(inv.grn_number), ''), - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') - ) IS NOT NULL - ORDER BY inv.created_at`, + `SELECT unit.vgm_tons AS "allocatedWeightTons", + unit.container_number AS "containerNumbers", + unit.seal_number AS "sealNumbers" + 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 = $1 + AND unit.deleted_at IS NULL + AND unit.received_to_port = true + AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL + ORDER BY unit.received_at, unit.container_number`, [bookingId], ) : []; diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 3df681d9c..d8b94efad 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -35,6 +35,7 @@ interface BookingGuardRow { lastMile: string | null; paymentStatus: string | null; status: string | null; + trainScheduleStatus: string | null; } /** @@ -294,19 +295,16 @@ export class CustomerTruckService { } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (booking.freightType === 'CONTAINER' && !requested.length) { + throw new BadRequestException('Select the containers loaded on this truck'); + } if (requested.length) { - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (elsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); } await this.dataSource.transaction(async (manager) => { @@ -542,9 +540,16 @@ export class CustomerTruckService { first_mile_pickup_address AS "firstMile", last_mile_delivery_address AS "lastMile", payment_status AS "paymentStatus", - status - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL`, + b.status, + (SELECT ts.status + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts + ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL + WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL + ORDER BY ts.updated_at DESC + LIMIT 1) AS "trainScheduleStatus" + FROM freight.bookings b + WHERE b.id = $1 AND b.deleted_at IS NULL`, [bookingId], ); if (!row) throw new NotFoundException(`Booking ${bookingId} not found`); @@ -575,7 +580,7 @@ export class CustomerTruckService { private assertAssignmentWindow(booking: BookingGuardRow): void { const status = booking.status ?? ''; if (booking.tradeDirection === 'IMPORT') { - if (status !== 'ARRIVED') { + if (status !== 'ARRIVED' && booking.trainScheduleStatus !== 'ARRIVED') { throw new BadRequestException( 'Import pickup trucks can only be assigned after the train has arrived', ); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 8dd0681ce..85417ea87 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,6 +1,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { + ArrayMaxSize, + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsBoolean, + IsIn, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Min, +} from 'class-validator'; import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @@ -187,6 +200,22 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + /** + * The physical containers delivered by this truck. Container exports are + * received one truck at a time: either one 40ft box or up to two 20ft boxes. + */ + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + @ApiPropertyOptional({ type: TruckEntranceDto }) @IsOptional() @ValidateNested() 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 5956f0bcf..eb3ae97b3 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 @@ -17,6 +17,7 @@ import { import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; +import { assertTruckLoad } from '../../common/truck-load.util'; import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import type { StationWorkLog } from '../train-schedules/entities/train-schedule.entity'; @@ -275,12 +276,30 @@ export interface EligibleBookingRow { customerTruckType: string | null; customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; + containerUnits: Array<{ + containerNumber: string; + containerSize: string | null; + weightTons: number; + received: boolean; + grnNumber: string | null; + }>; + receivedContainerCount: number; + remainingContainerCount: number; } export interface BulkReceiveResult { receivedCount: number; skippedCount: number; - results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; + results: { + bookingId: string; + status: string; + inventoryId?: string; + inventoryIds?: string[]; + grnNumber?: string; + receivedContainers?: number; + remainingContainers?: number; + reason?: string; + }[]; } @@ -1406,6 +1425,9 @@ export class WarehouseInventoryService { ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", bcu.seal_numbers AS "sealNumbers", + COALESCE(bcu.container_units, '[]'::json) AS "containerUnits", + COALESCE(bcu.received_count, 0)::int AS "receivedContainerCount", + COALESCE(bcu.remaining_count, 0)::int AS "remainingContainerCount", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", -- service_types.includes_last_mile/first_mile are NOT read here: every @@ -1457,7 +1479,6 @@ export class WarehouseInventoryService { LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id - LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, SUM(booking_container.quantity)::int AS container_quantity, @@ -1476,7 +1497,18 @@ export class WarehouseInventoryService { ) 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 + string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers, + COUNT(*) FILTER (WHERE unit.received_to_port)::int AS received_count, + COUNT(*) FILTER (WHERE NOT unit.received_to_port)::int AS remaining_count, + json_agg( + json_build_object( + 'containerNumber', unit.container_number, + 'containerSize', line.container_size, + 'weightTons', unit.vgm_tons, + 'received', unit.received_to_port, + 'grnNumber', unit.grn_number + ) ORDER BY unit.container_number + ) AS container_units FROM freight.booking_container_units unit JOIN freight.booking_container line ON line.id = unit.booking_container_id AND line.deleted_at IS NULL @@ -1493,7 +1525,14 @@ export class WarehouseInventoryService { LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' - AND inv.id IS NULL + AND ( + (b.freight_type = 'CONTAINER' AND COALESCE(bcu.remaining_count, 0) > 0) + OR + (b.freight_type <> 'CONTAINER' AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = b.id AND inv.deleted_at IS NULL + )) + ) -- Direct truck-to-train cargo never comes to the warehouse, so never -- offer it for receipt. AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN' @@ -1650,9 +1689,6 @@ export class WarehouseInventoryService { } } - const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); - if (existing) { skip('Already received'); continue; } - const containerQuantity = Number(booking.containerQuantity ?? 0); if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { skip('Container booking has no container quantity'); @@ -1660,62 +1696,256 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; + // Multi-truck self-haul is selected explicitly at the gate. The booking + // source contains comma-joined legacy summary fields, which must never + // replace the one physical truck the receiver selected. + if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) { + truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber; + truckEntrance.driverName = dto.truckEntrance.driverName; + truckEntrance.driverPhone = dto.truckEntrance.driverPhone; + truckEntrance.truckType = dto.truckEntrance.truckType; + } if (dto.direction === 'EXPORT') { this.assertTruckEntrance(truckEntrance); } + + type ReceiveContainerUnit = { + containerNumber: string; + containerSize: string | null; + weightTons: string | number; + sealNumber: string | null; + bookingContainerId: string; + containerTypeId: string | null; + received: boolean; + }; + let selectedUnits: ReceiveContainerUnit[] = []; + let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); + + if (booking.freightType === 'CONTAINER') { + if (dto.bookingIds.length !== 1) { + throw new BadRequestException( + 'Receive one container booking per arriving truck so its containers and documents stay separate', + ); + } + const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!selectedNumbers.length) { + throw new BadRequestException('Select the containers arriving on this truck'); + } + const allUnits: ReceiveContainerUnit[] = await manager.query( + `SELECT UPPER(bcu.container_number) AS "containerNumber", + bc.container_size AS "containerSize", + bcu.vgm_tons AS "weightTons", + bcu.seal_number AS "sealNumber", + bc.id AS "bookingContainerId", + bc.container_type_id AS "containerTypeId", + bcu.received_to_port AS received + 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 + FOR UPDATE OF bcu`, + [bookingId], + ); + assertTruckLoad({ + containers: selectedNumbers, + bookingContainers: allUnits.map((unit) => unit.containerNumber), + sizes: allUnits + .filter((unit) => selectedNumbers.includes(unit.containerNumber)) + .map((unit) => unit.containerSize ?? ''), + }); + selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber)); + if (selectedUnits.some((unit) => unit.received)) { + const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber); + throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`); + } + + // If this is a customer-assigned truck, it may only deliver the boxes + // assigned to that plate. Manual/unassigned arrivals retain the same + // physical capacity validation but have no assignment list to check. + if (truckEntrance?.truckPlateNumber) { + const assigned: Array<{ containerNumber: string }> = await manager.query( + `SELECT UPPER(ctc.container_number) AS "containerNumber" + FROM freight.customer_truck_assignments cta + JOIN freight.customer_truck_containers ctc + ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL + WHERE cta.booking_id = $1 + AND UPPER(cta.plate_number) = UPPER($2) + AND cta.deleted_at IS NULL`, + [bookingId, truckEntrance.truckPlateNumber], + ); + if ( + assigned.length > 0 && + selectedNumbers.some( + (number) => !assigned.some((container) => container.containerNumber === number), + ) + ) { + throw new BadRequestException( + `Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`, + ); + } + } + + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT inv.grn_number) AS batches + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.grn_number IS NOT NULL + AND inv.deleted_at IS NULL`, + [bookingId], + ); + grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`; + if (truckEntrance) { + truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', '); + truckEntrance.unitCount = selectedNumbers.length; + truckEntrance.netWeightKg = selectedUnits.reduce( + (total, unit) => total + Number(unit.weightTons || 0), + 0, + ); + } + } else { + const existing = await manager + .getRepository(WarehouseInventory) + .findOne({ where: { bookingId } }); + if (existing) { + skip('Already received'); + continue; + } + } + + const receivedBefore = + booking.freightType === 'CONTAINER' + ? Number( + ( + await manager.query( + `SELECT COUNT(*) AS count + 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.received_to_port = true + AND bcu.deleted_at IS NULL`, + [bookingId], + ) + )[0]?.count ?? 0, + ) + : 0; + const receivedAfter = receivedBefore + selectedUnits.length; + const remainingAfter = Math.max(0, containerQuantity - receivedAfter); const receiveNote = this.buildReceiveNote({ grnNumber, direction: dto.direction, - notes: `Bulk received (${dto.direction})`, + notes: + booking.freightType === 'CONTAINER' + ? `${selectedUnits.length} container(s) arrived: ${selectedUnits + .map((unit) => unit.containerNumber) + .join(', ')}. ${remainingAfter} container(s) left.` + : `Bulk received (${dto.direction})`, truckEntrance, }); // Validate capacity before saving - const weight = Number(booking.weight) || 0; - const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + const weight = + booking.freightType === 'CONTAINER' + ? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0) + : Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0; this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); this.assertCapacity('Yard', yard, weight, 0, containerCount); this.assertCapacity('Zone', zone, weight, 0, containerCount); - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ - warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - bookingId, - quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight, - grnNumber, - status: 'RECEIVED', - arrivedAt: now, - notes: receiveNote, - }), - ); + const inventoryIds: string[] = []; + if (booking.freightType === 'CONTAINER') { + const containers = manager.getRepository(Container); + for (const unit of selectedUnits) { + let container = await containers.findOne({ + where: { containerNumber: unit.containerNumber }, + withDeleted: true, + }); + if (!container && !unit.containerTypeId) { + throw new BadRequestException( + `Container ${unit.containerNumber} has no container type and cannot be received`, + ); + } + if (!container) { + container = await containers.save( + containers.create({ + containerNumber: unit.containerNumber, + containerTypeId: unit.containerTypeId as string, + bookingContainerId: unit.bookingContainerId, + bookingId, + sealNumber: unit.sealNumber, + tareWeight: 0, + maxGrossWeight: Number(unit.weightTons || 0), + status: 'LOADED', + wagonId: null, + position: null, + wagonBookingAllocationId: null, + }), + ); + } else { + await containers.update(container.id, { + bookingId, + bookingContainerId: unit.bookingContainerId, + sealNumber: unit.sealNumber, + status: 'LOADED', + deletedAt: null, + }); + } + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + containerId: container.id, + quantity: 1, + weight: Number(unit.weightTons || 0), + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, + }), + ); + inventoryIds.push(saved.id); + } + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + grn_number = $3, + updated_at = NOW() + FROM freight.booking_container bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2::varchar[]) + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL`, + [bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber], + ); + } else { + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + quantity: 1, + weight, + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, + }), + ); + inventoryIds.push(saved.id); + } // Update warehouse/yard/zone capacity counters await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); - // Receiving the booking flags every container unit as received into the - // port (self-haul export: the delivering truck's goods are now in) so - // staff can raise the per-container GRN over what's received. - await manager.query( - `UPDATE freight.booking_container_units bcu - SET received_to_port = true, - received_at = COALESCE(bcu.received_at, NOW()), - updated_at = NOW() - FROM freight.booking_container bc - WHERE bc.id = bcu.booking_container_id - AND bc.booking_id = $1 - AND bc.deleted_at IS NULL - AND bcu.deleted_at IS NULL - AND bcu.received_to_port = false`, - [bookingId], - ); - // Export self-haul: this receive IS the truck's arrival — see // markCustomerTruckArrived / receive()'s single-booking mirror. if (dto.direction === 'EXPORT') { @@ -1725,7 +1955,7 @@ export class WarehouseInventoryService { await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, + inventoryId: inventoryIds[0], warehouseId: dto.warehouseId, description: truckEntrance?.truckPlateNumber ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` @@ -1752,7 +1982,19 @@ export class WarehouseInventoryService { }); result.receivedCount += 1; - result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + result.results.push({ + bookingId, + status: 'RECEIVED', + inventoryId: inventoryIds[0], + inventoryIds, + grnNumber, + ...(booking.freightType === 'CONTAINER' + ? { + receivedContainers: receivedAfter, + remainingContainers: remainingAfter, + } + : {}), + }); } }); @@ -4026,7 +4268,7 @@ export class WarehouseInventoryService { `SELECT inv.id, inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", - inv.quantity, + COALESCE(receipt_batch.quantity, inv.quantity) AS quantity, inv.weight, inv.status, inv.notes, @@ -4355,11 +4597,12 @@ export class WarehouseInventoryService { */ async bookingContainerWeights( bookingId: string, - ): Promise> { - const rows: Array<{ containerNumber: string; weightTons: string }> = + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string; containerSize: string | null }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", - MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons" + MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons", + MAX(bc.container_size) AS "containerSize" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -4371,6 +4614,7 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, weightTons: Number(r.weightTons) || 0, + containerSize: r.containerSize ?? null, })); } @@ -4572,7 +4816,7 @@ export class WarehouseInventoryService { -- An unweighed item still reports the cargo weight it holds: fall -- back to the item's container VGM, then the booking's declared -- weight, so a GRN never prints "0 t" for goods that are present. - COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, + COALESCE(NULLIF(receipt_batch.weight, 0), NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, inv.volume, inv.status, inv.notes, @@ -4589,8 +4833,8 @@ export class WarehouseInventoryService { origin_yard.code AS "originYardCode", destination_yard.label AS "destinationYardLabel", destination_yard.code AS "destinationYardCode", - COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", - booking_container."containerSummary" AS "bookingContainerSummary", + COALESCE(receipt_batch.container_numbers, container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(receipt_batch.container_summary, booking_container."containerSummary") AS "bookingContainerSummary", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -4620,6 +4864,40 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS quantity, + SUM(batch.weight) AS weight, + string_agg(batch.container_number, ', ' ORDER BY batch.container_number) + FILTER (WHERE batch.container_number IS NOT NULL) AS container_numbers, + string_agg( + CONCAT(batch.container_number, ' (', COALESCE(batch.container_size, 'size unknown'), ')'), + ', ' ORDER BY batch.container_number + ) FILTER (WHERE batch.container_number IS NOT NULL) AS container_summary + FROM ( + SELECT inv2.id, + inv2.weight, + c2.container_number, + bc2.container_size + FROM freight.warehouse_inventory inv2 + LEFT JOIN freight.containers c2 + ON c2.id = inv2.container_id AND c2.deleted_at IS NULL + LEFT JOIN freight.booking_container_units bcu2 + ON bcu2.container_number = c2.container_number AND bcu2.deleted_at IS NULL + LEFT JOIN freight.booking_container bc2 + ON bc2.id = bcu2.booking_container_id + AND bc2.booking_id = inv2.booking_id + AND bc2.deleted_at IS NULL + WHERE inv2.booking_id = inv.booking_id + AND inv2.deleted_at IS NULL + AND COALESCE( + NULLIF(TRIM(inv2.grn_number), ''), + substring(inv2.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) = COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) + ) batch + ) receipt_batch ON true LEFT JOIN LATERAL ( SELECT SUM(bcu.vgm_tons) AS tons FROM freight.booking_container_units bcu 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 ba75f0c35..b10e937c3 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -58,6 +58,7 @@ import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses'; import { firstMileService } from '@/services/first-mile.service'; +import { bookingsService } from '@/services/bookings.service'; import { warehouseService } from '@/services/warehouse.service'; import type { EligibleBooking, @@ -858,6 +859,8 @@ function EligibleTab({ const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); const [lockedTruckFields, setLockedTruckFields] = useState({}); const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); + const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState(null); + const [selectedContainerNumbers, setSelectedContainerNumbers] = useState([]); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -944,6 +947,105 @@ function EligibleTab({ const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile); const pendingUsesFirstMile = pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile); + const pendingContainerBooking = + pendingReceiveRows.length === 1 && pendingReceiveRows[0]?.freightType === 'CONTAINER' + ? pendingReceiveRows[0] + : null; + const { data: assignedCustomerTrucks = [] } = useQuery({ + queryKey: ['receive-customer-trucks', pendingContainerBooking?.id], + queryFn: () => warehouseService.getCustomerTrucks(pendingContainerBooking?.id as string), + enabled: truckOpen && Boolean(pendingContainerBooking) && !pendingUsesFirstMile, + }); + const pendingContainerUnits = (pendingContainerBooking?.containerUnits ?? []).filter( + (unit) => !unit.received, + ); + const selectedCustomerTruck = assignedCustomerTrucks.find( + (truck) => truck.id === selectedCustomerTruckId, + ); + const assignedNumbersForSelectedTruck = new Set( + (selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()), + ); + const selectableContainerUnits = pendingContainerUnits.filter( + (unit) => + assignedNumbersForSelectedTruck.size === 0 || + assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()), + ); + const selectedContainerUnits = pendingContainerUnits.filter((unit) => + selectedContainerNumbers.includes(unit.containerNumber), + ); + const selectedContainerWeight = selectedContainerUnits.reduce( + (total, unit) => total + Number(unit.weightTons || 0), + 0, + ); + const containerCapacityError = + selectedContainerNumbers.length > 2 + ? 'A truck carries no more than 2 containers.' + : selectedContainerNumbers.length > 1 && + selectedContainerUnits.some((unit) => !String(unit.containerSize ?? '').includes('20')) + ? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.' + : null; + + useEffect(() => { + if (!truckOpen || pendingUsesFirstMile || !pendingContainerBooking) return; + if (selectedCustomerTruckId || assignedCustomerTrucks.length === 0) return; + const pendingNumbers = new Set( + pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()), + ); + const truck = + assignedCustomerTrucks.find( + (candidate) => + !candidate.arrivedAt && + (candidate.containers ?? []).some((container) => + pendingNumbers.has(container.containerNumber.toUpperCase()), + ), + ) ?? assignedCustomerTrucks[0]; + const truckContainers = (truck.containers ?? []) + .map((container) => container.containerNumber.toUpperCase()) + .filter((number) => pendingNumbers.has(number)); + setSelectedCustomerTruckId(truck.id); + setSelectedContainerNumbers(truckContainers); + setTruckForm((current) => ({ + ...current, + truckPlateNumber: truck.plateNumber, + driverName: truck.driverName, + truckType: truck.truckType, + assignedEquipmentNumber: truckContainers.join(', '), + unitCount: truckContainers.length, + netWeightKg: pendingContainerUnits + .filter((unit) => truckContainers.includes(unit.containerNumber.toUpperCase())) + .reduce((total, unit) => total + Number(unit.weightTons || 0), 0), + })); + setLockedTruckFields((current) => ({ + ...current, + truckPlateNumber: true, + driverName: true, + truckType: true, + assignedEquipmentNumber: true, + unitCount: true, + })); + }, [ + assignedCustomerTrucks, + pendingContainerBooking, + pendingContainerUnits, + pendingUsesFirstMile, + selectedCustomerTruckId, + truckOpen, + ]); + + useEffect(() => { + if (!truckOpen || !pendingContainerBooking) return; + setTruckForm((current) => ({ + ...current, + assignedEquipmentNumber: selectedContainerNumbers.join(', '), + unitCount: selectedContainerNumbers.length, + netWeightKg: selectedContainerWeight, + })); + }, [ + pendingContainerBooking, + selectedContainerNumbers, + selectedContainerWeight, + truckOpen, + ]); const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); @@ -954,29 +1056,60 @@ function EligibleTab({ return next; }); - const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => { + const receiveBookings = async ( + bookingIds: string[], + truckEntrance?: TruckEntrancePayload, + containerNumbers?: string[], + ) => { + const documentBookingId = direction === 'EXPORT' && bookingIds.length === 1 ? bookingIds[0] : null; + const grnWindow = documentBookingId ? window.open('', '_blank') : null; + const acceptanceWindow = documentBookingId ? window.open('', '_blank') : null; try { const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds, + ...(containerNumbers?.length ? { containerNumbers } : {}), ...(truckEntrance ? { truckEntrance } : {}), }); + const receivedProgress = r.results.find( + (item) => item.receivedContainers != null && item.remainingContainers != null, + ); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results), + description: receivedProgress + ? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.` + : r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results), }); const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber); - if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) { - const pdfWindow = window.open('', '_blank'); + if (documentBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) { try { const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId); - const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow); + const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, grnWindow); toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); } catch (error) { - pdfWindow?.close(); + grnWindow?.close(); toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); } + try { + const acceptance = await bookingsService.downloadCarriageAcceptanceSheet(documentBookingId); + const opened = openPdfBlob( + acceptance, + `carriage-acceptance-${pendingReceiveRows[0]?.reference ?? documentBookingId}.pdf`, + acceptanceWindow, + ); + toast({ title: opened ? 'Carriage acceptance sheet opened' : 'Carriage acceptance sheet downloaded' }); + } catch (error) { + acceptanceWindow?.close(); + toast({ + variant: 'destructive', + title: 'Carriage acceptance sheet failed', + description: await extractDownloadErrorMessage(error), + }); + } + } else { + grnWindow?.close(); + acceptanceWindow?.close(); } setSelected(new Set()); setTruckOpen(false); @@ -984,8 +1117,12 @@ function EligibleTab({ setReceivedAt(null); setLockedTruckFields({}); setPackagingFreightType('MIXED'); + setSelectedCustomerTruckId(null); + setSelectedContainerNumbers([]); onChanged?.(); } catch (error) { + grnWindow?.close(); + acceptanceWindow?.close(); toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); } }; @@ -1012,6 +1149,18 @@ function EligibleTab({ void receiveBookings(filteredIds); return; } + if ( + direction === 'EXPORT' && + selectedRows.some((row) => row.freightType === 'CONTAINER') && + selectedRows.length !== 1 + ) { + toast({ + variant: 'destructive', + title: 'Receive one container booking per truck', + description: 'Select the arriving truck and its 1 x 40ft or up to 2 x 20ft containers.', + }); + return; + } const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile); const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile); if (hasFirstMileRows && hasCustomerTruckRows) { @@ -1041,6 +1190,8 @@ function EligibleTab({ ...form, }; setPendingReceiveIds(filteredIds); + setSelectedCustomerTruckId(null); + setSelectedContainerNumbers([]); setReceivedAt(new Date().toISOString()); setTruckForm(normalizedForm); setLockedTruckFields({ @@ -1073,7 +1224,70 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' }); return; } - await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm)); + if (pendingContainerBooking && selectedContainerNumbers.length === 0) { + toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' }); + return; + } + if (containerCapacityError) { + toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError }); + return; + } + await receiveBookings( + pendingReceiveIds, + toTruckEntrancePayload({ + ...truckForm, + ...(pendingContainerBooking + ? { + assignedEquipmentNumber: selectedContainerNumbers.join(', '), + unitCount: selectedContainerNumbers.length, + netWeightKg: selectedContainerWeight, + } + : {}), + }), + pendingContainerBooking ? selectedContainerNumbers : undefined, + ); + }; + + const chooseCustomerTruck = (truckId: string | null) => { + setSelectedCustomerTruckId(truckId); + const truck = assignedCustomerTrucks.find((candidate) => candidate.id === truckId); + if (!truck) { + setSelectedContainerNumbers([]); + setLockedTruckFields((current) => ({ + ...current, + truckPlateNumber: false, + driverName: false, + truckType: false, + })); + return; + } + const pendingNumbers = new Set( + pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()), + ); + const containers = (truck.containers ?? []) + .map((container) => container.containerNumber.toUpperCase()) + .filter((number) => pendingNumbers.has(number)); + const weight = pendingContainerUnits + .filter((unit) => containers.includes(unit.containerNumber.toUpperCase())) + .reduce((total, unit) => total + Number(unit.weightTons || 0), 0); + setSelectedContainerNumbers(containers); + setTruckForm((current) => ({ + ...current, + truckPlateNumber: truck.plateNumber, + driverName: truck.driverName, + truckType: truck.truckType, + assignedEquipmentNumber: containers.join(', '), + unitCount: containers.length, + netWeightKg: weight, + })); + setLockedTruckFields((current) => ({ + ...current, + truckPlateNumber: true, + driverName: true, + truckType: true, + assignedEquipmentNumber: true, + unitCount: true, + })); }; @@ -1296,6 +1510,76 @@ function EligibleTab({ : 'Register the customer or third-party truck and driver before export receiving and GRN.'} + {pendingContainerBooking && ( + + } color="teal" variant="light"> + + + {pendingContainerBooking.receivedContainerCount + selectedContainerNumbers.length} containers arrived + + + · {Math.max( + 0, + pendingContainerBooking.remainingContainerCount - selectedContainerNumbers.length, + )} left after this receipt + + + This truck: {selectedContainerNumbers.length} + + + + {assignedCustomerTrucks.length > 0 && !pendingUsesFirstMile && ( +