Merge pull request #1271 from Tria-plc/eims-integration

Eims integration

feat(lastmile): reference stored LM contract from booking detail
This commit is contained in:
Hagernesh Tadesse
2026-08-13 13:18:16 +03:00
committed by GitHub
7 changed files with 96 additions and 34 deletions

View File

@@ -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 () => {

View File

@@ -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<EimsInvoiceStatusView> {
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.

View File

@@ -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);

View File

@@ -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<void> {
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}`);
}
}

View File

@@ -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' };

View File

@@ -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) => {

View File

@@ -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<void> {
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: {