mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
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:
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
@@ -96,14 +96,16 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
|
|||||||
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
|
expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => {
|
it("refuses re-cancelling an already-cancelled invoice, per IRC-N010 — no silent no-op", async () => {
|
||||||
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
|
const db = new FakeDb([
|
||||||
|
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancellationDate: "Sun Dec 22 2024" }),
|
||||||
|
]);
|
||||||
const postBearer = jest.fn();
|
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(postBearer).not.toHaveBeenCalled();
|
||||||
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses to cancel an invoice that was never registered", async () => {
|
it("refuses to cancel an invoice that was never registered", async () => {
|
||||||
|
|||||||
@@ -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 { InjectDataSource } from "@nestjs/typeorm";
|
||||||
import { DataSource, EntityManager } from "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
|
* Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in
|
||||||
* that was never registered — there is no IRN to cancel.
|
* 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(
|
async cancelInvoiceWithEims(
|
||||||
invoiceId: string,
|
invoiceId: string,
|
||||||
@@ -48,7 +51,12 @@ export class EimsCancellationService {
|
|||||||
): Promise<EimsInvoiceStatusView> {
|
): Promise<EimsInvoiceStatusView> {
|
||||||
const eligible = await this.dataSource.transaction(async (manager) => {
|
const eligible = await this.dataSource.transaction(async (manager) => {
|
||||||
const invoice = await this.lockInvoice(manager, invoiceId);
|
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) {
|
if (!invoice.eimsIrn) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: "EIMS_NOT_REGISTERED",
|
code: "EIMS_NOT_REGISTERED",
|
||||||
@@ -57,7 +65,6 @@ export class EimsCancellationService {
|
|||||||
}
|
}
|
||||||
return invoice;
|
return invoice;
|
||||||
});
|
});
|
||||||
if (!eligible) return this.getEimsCancellationStatus(invoiceId);
|
|
||||||
|
|
||||||
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
|
const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" };
|
||||||
// Outside any transaction — no DB lock is held across the wire.
|
// Outside any transaction — no DB lock is held across the wire.
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export class EimsInvoiceController {
|
|||||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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) {
|
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
|
||||||
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import {
|
import {
|
||||||
companyNotifyEmailExpr,
|
companyNotifyEmailExpr,
|
||||||
companyNotifyPhoneExpr,
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
|
|||||||
{} as never, // yardFacilities
|
{} as never, // yardFacilities
|
||||||
{} as never, // facilityHandling
|
{} as never, // facilityHandling
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
|
{} as never, // notifications
|
||||||
|
{} as never, // inbox
|
||||||
);
|
);
|
||||||
|
|
||||||
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
|
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
|
|||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.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.
|
* 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 yardFacilities: YardFacilitiesService,
|
||||||
private readonly facilityHandling: FacilityHandlingService,
|
private readonly facilityHandling: FacilityHandlingService,
|
||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@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,
|
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||||
// however it arrived and whatever it is allocated to.
|
// however it arrived and whatever it is allocated to.
|
||||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
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();
|
const now = new Date();
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte
|
|||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
import {
|
||||||
|
sendCompanyChannels,
|
||||||
|
notifyCarriageAcceptanceReady as notifyCarriageAcceptanceReadyShared,
|
||||||
|
} from '../notifications/notify-company.util';
|
||||||
import {
|
import {
|
||||||
companyNotifyPhoneExpr,
|
companyNotifyPhoneExpr,
|
||||||
primaryContactUserJoin,
|
primaryContactUserJoin,
|
||||||
@@ -6167,26 +6170,13 @@ export class WarehouseInventoryService {
|
|||||||
* fires right after receive, not at marshalling.
|
* fires right after receive, not at marshalling.
|
||||||
*/
|
*/
|
||||||
private async notifyCarriageAcceptanceReady(bookingId: string): Promise<void> {
|
private async notifyCarriageAcceptanceReady(bookingId: string): Promise<void> {
|
||||||
try {
|
await notifyCarriageAcceptanceReadyShared(
|
||||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
this.dataSource,
|
||||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
this.notifications,
|
||||||
[bookingId],
|
this.inbox,
|
||||||
);
|
bookingId,
|
||||||
if (!b?.companyId) return;
|
this.logger,
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyOwnerInventoryReceived(params: {
|
private async notifyOwnerInventoryReceived(params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user