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); 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: {