From 89dba01cd797ed561341195fccac1c449e8957fd Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 09:27:01 +0000 Subject: [PATCH 1/2] feat(train-scheduling): notify customer of CAS on direct-to-train load Direct truck-to-train export cargo skips the warehouse, so the existing carriage acceptance sheet ready notice (fired on warehouse receive) never reached these bookings. Their handover moment is the load itself. Extract notifyCarriageAcceptanceReady into a shared notifications util (was private to WarehouseInventoryService) and call it from BookingJourneyService.loadBooking for EXPORT + DIRECT_TO_TRAIN bookings, right after the GRN gate, before the load transaction proceeds. --- .../notifications/notify-company.util.ts | 41 +++++++++++++++++++ .../booking-journey.service.spec.ts | 2 + .../booking-journey.service.ts | 22 +++++++++- .../warehouses/warehouse-inventory.service.ts | 32 +++++---------- 4 files changed, 75 insertions(+), 22 deletions(-) diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index e3b2fd80a..467b172ba 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -1,6 +1,9 @@ import { DataSource } from 'typeorm'; +import { Logger } from '@nestjs/common'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationsService } from './notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { companyNotifyEmailExpr, companyNotifyPhoneExpr, @@ -42,3 +45,41 @@ export async function sendCompanyChannels( } } } + +/** + * Tell the customer their export carriage acceptance sheet is ready to + * download from the portal — the sheet itself is generated on demand by + * BookingsService.carriageAcceptanceSheet, never stored, so this is a + * "ready" notice + link, not an attachment (the email pipeline carries text + * only). Shared by every path that makes a booking's handover final: the + * warehouse gate on receive, and direct truck-to-train on load (that cargo + * never sees a warehouse, so its handover moment IS the load). + */ +export async function notifyCarriageAcceptanceReady( + dataSource: DataSource, + notifications: NotificationsService, + inbox: NotificationInboxService, + bookingId: string, + logger: Logger, +): Promise { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; + await inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Carriage acceptance sheet ready', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, reference: b.reference }, + }); + await sendCompanyChannels(dataSource, notifications, b.companyId, body); + } catch (err) { + logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts index 0a8cc99fe..ea936465e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -11,6 +11,8 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { {} as never, // yardFacilities {} as never, // facilityHandling { emit: jest.fn() } as never, // events + {} as never, // notifications + {} as never, // inbox ); const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index bf6ab5069..56b28fb7f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -24,7 +24,10 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; -import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; +import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -52,6 +55,8 @@ export class BookingJourneyService { private readonly yardFacilities: YardFacilitiesService, private readonly facilityHandling: FacilityHandlingService, private readonly events: EventEmitter2, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -76,6 +81,21 @@ export class BookingJourneyService { // Export cargo must be in the warehouse with a GRN before it can be loaded, // however it arrived and whatever it is allocated to. await assertExportReceivedWithGrn(this.dataSource, booking); + // Direct truck-to-train cargo never sees the warehouse, so loading IS its + // handover moment — the carriage acceptance sheet must go out to the + // customer right here, not on a receive event that will never fire. + if ( + booking.tradeDirection === 'EXPORT' && + booking.exportHandoverMode === DIRECT_TO_TRAIN + ) { + await notifyCarriageAcceptanceReady( + this.dataSource, + this.notifications, + this.inbox, + booking.id, + this.logger, + ); + } const now = new Date(); await this.dataSource.transaction(async (manager) => { 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 239485d3c..163b5bcc1 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 @@ -28,7 +28,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte import { LastMileService } from '../last-mile/last-mile.service'; import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto'; import { NotificationsService } from '../notifications/notifications.service'; -import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { + sendCompanyChannels, + notifyCarriageAcceptanceReady as notifyCarriageAcceptanceReadyShared, +} from '../notifications/notify-company.util'; import { companyNotifyPhoneExpr, primaryContactUserJoin, @@ -6167,26 +6170,13 @@ export class WarehouseInventoryService { * fires right after receive, not at marshalling. */ private async notifyCarriageAcceptanceReady(bookingId: string): Promise { - try { - const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( - `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); - if (!b?.companyId) return; - const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; - await this.inbox.notify({ - recipients: { companyId: b.companyId }, - audience: NotificationAudience.PORTAL, - type: NotificationType.DOCUMENT_ACTION, - title: 'Carriage acceptance sheet ready', - body, - link: `/bookings/${bookingId}`, - data: { bookingId, reference: b.reference }, - }); - await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); - } catch (err) { - this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); - } + await notifyCarriageAcceptanceReadyShared( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + this.logger, + ); } private async notifyOwnerInventoryReceived(params: { From e66e622ededffd804fe61f840824a914309ee696 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 09:52:43 +0000 Subject: [PATCH 2/2] eims cancellation --- .../eims/eims-cancellation.service.spec.ts | 14 ++++++++------ .../modules/eims/eims-cancellation.service.ts | 17 ++++++++++++----- .../src/modules/eims/eims-invoice.controller.ts | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts index ab5ea2548..6bd9b04b3 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from "@nestjs/common"; +import { BadRequestException, ConflictException } from "@nestjs/common"; import { DataSource } from "typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; @@ -96,14 +96,16 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => { expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" }); }); - it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => { - const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]); + it("refuses re-cancelling an already-cancelled invoice, per IRC-N010 — no silent no-op", async () => { + const db = new FakeDb([ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancellationDate: "Sun Dec 22 2024" }), + ]); const postBearer = jest.fn(); - const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1"); - + await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf( + ConflictException, + ); expect(postBearer).not.toHaveBeenCalled(); - expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); }); it("refuses to cancel an invoice that was never registered", async () => { diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts index 83ff16704..73c3e87f4 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource, EntityManager } from "typeorm"; @@ -38,8 +38,11 @@ export class EimsCancellationService { ) {} /** - * Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice - * that was never registered — there is no IRN to cancel. + * Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in + * MoR's Master Compliance Checklist requires "an appropriate error or rejection message" for a + * repeat cancellation, not a quiet success. No HTTP call either way: this is a local check, not + * a retry against MoR. Also refuses an invoice that was never registered — there is no IRN to + * cancel. */ async cancelInvoiceWithEims( invoiceId: string, @@ -48,7 +51,12 @@ export class EimsCancellationService { ): Promise { const eligible = await this.dataSource.transaction(async (manager) => { const invoice = await this.lockInvoice(manager, invoiceId); - if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null; + if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new ConflictException({ + code: "EIMS_ALREADY_CANCELLED", + message: `Invoice ${invoice.invoiceNumber} was already cancelled with EIMS${invoice.eimsCancellationDate ? ` (${invoice.eimsCancellationDate})` : ""}.`, + }); + } if (!invoice.eimsIrn) { throw new BadRequestException({ code: "EIMS_NOT_REGISTERED", @@ -57,7 +65,6 @@ export class EimsCancellationService { } return invoice; }); - if (!eligible) return this.getEimsCancellationStatus(invoiceId); const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" }; // Outside any transaction — no DB lock is held across the wire. diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 5db756329..c02c7870e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -81,7 +81,7 @@ export class EimsInvoiceController { @BookingStaff(FREIGHT_PERMS.invoices.eimsCancel) @ApiOperation({ summary: - "Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.", + "Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled invoice rather than a silent no-op — see IRC-N010.", }) cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) { return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);