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.
This commit is contained in:
Hagernesh
2026-08-13 09:27:01 +00:00
parent ae6797ea05
commit 89dba01cd7
4 changed files with 75 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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