mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
Notify customer on Sign handover, truck assignment, arrival and dispatch of train
This commit is contained in:
@@ -71,6 +71,24 @@ export class BookingNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Train carrying the booking departed — dispatched origin → destination. */
|
||||||
|
dispatched(b: Booking, origin: string | null, destination: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`Your booking ${b.reference ?? b.id} has been dispatched` +
|
||||||
|
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
|
||||||
|
void this.notifyContact(b, msg, 'DISPATCHED');
|
||||||
|
this.inApp(b, 'Shipment dispatched', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Train carrying the booking arrived at destination. */
|
||||||
|
arrived(b: Booking, origin: string | null, destination: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`Your booking ${b.reference ?? b.id} has arrived` +
|
||||||
|
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
|
||||||
|
void this.notifyContact(b, msg, 'ARRIVED');
|
||||||
|
this.inApp(b, 'Shipment arrived', msg);
|
||||||
|
}
|
||||||
|
|
||||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
{
|
{
|
||||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||||
} as never, // bookingJourneyService
|
} as never, // bookingJourneyService
|
||||||
|
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFleetWagons = [
|
const defaultFleetWagons = [
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d
|
|||||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import {
|
import {
|
||||||
buildCappedWagonPlan,
|
buildCappedWagonPlan,
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
@@ -281,10 +282,38 @@ export class TrainSchedulingService {
|
|||||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
private readonly bookingJourneyService: BookingJourneyService,
|
private readonly bookingJourneyService: BookingJourneyService,
|
||||||
|
private readonly bookingNotifier: BookingNotifierService,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
private readonly configService?: ConfigService,
|
private readonly configService?: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||||||
|
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||||||
|
*/
|
||||||
|
private async notifyScheduleBookings(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
event: 'dispatched' | 'arrived',
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
|
||||||
|
if (!ids.length) return;
|
||||||
|
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
|
||||||
|
const destination =
|
||||||
|
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
|
||||||
|
const bookings = await this.dataSource.getRepository(Booking).find({
|
||||||
|
where: { id: In(ids) },
|
||||||
|
relations: { company: true },
|
||||||
|
});
|
||||||
|
for (const b of bookings) {
|
||||||
|
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||||||
|
else this.bookingNotifier.arrived(b, origin, destination);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete customer-tracking clearance milestones for every booking on a
|
* Complete customer-tracking clearance milestones for every booking on a
|
||||||
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||||||
@@ -1545,6 +1574,7 @@ export class TrainSchedulingService {
|
|||||||
{ originYardId: schedule.originStationId },
|
{ originYardId: schedule.originStationId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2550,6 +2580,7 @@ export class TrainSchedulingService {
|
|||||||
{ destinationYardId: schedule.destinationStationId },
|
{ destinationYardId: schedule.destinationStationId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
void this.notifyScheduleBookings(schedule, 'arrived');
|
||||||
|
|
||||||
const detail = await this.getTrainScheduleById(scheduleId);
|
const detail = await this.getTrainScheduleById(scheduleId);
|
||||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||||
|
|
||||||
import { BookingHandover } from './entities/booking-handover.entity';
|
import { BookingHandover } from './entities/booking-handover.entity';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||||
@@ -13,7 +15,32 @@ import { BookingHandover } from './entities/booking-handover.entity';
|
|||||||
export class HandoverService {
|
export class HandoverService {
|
||||||
private readonly logger = new Logger(HandoverService.name);
|
private readonly logger = new Logger(HandoverService.name);
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Tell the customer a handover is ready and needs their signature. */
|
||||||
|
private async notifySignNeeded(bookingId: string, reference: 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;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: b.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
title: 'Handover — signature needed',
|
||||||
|
body: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`,
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: { bookingId, reference },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
list(bookingId: string): Promise<BookingHandover[]> {
|
list(bookingId: string): Promise<BookingHandover[]> {
|
||||||
return this.dataSource.getRepository(BookingHandover).find({
|
return this.dataSource.getRepository(BookingHandover).find({
|
||||||
@@ -54,6 +81,7 @@ export class HandoverService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
||||||
|
void this.notifySignNeeded(bookingId, reference);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
|||||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||||
import { HandoverService } from './handover.service';
|
import { HandoverService } from './handover.service';
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
|
||||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||||
@@ -383,8 +385,39 @@ export class WarehouseInventoryService {
|
|||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly signatures: SignaturesService,
|
private readonly signatures: SignaturesService,
|
||||||
private readonly handover: HandoverService,
|
private readonly handover: HandoverService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a self-haul booking (no EDR first/last mile) is received to the warehouse
|
||||||
|
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||||
|
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||||
|
*/
|
||||||
|
private async notifyTruckAssignmentNeeded(booking: {
|
||||||
|
companyId?: string | null;
|
||||||
|
reference?: string | null;
|
||||||
|
hasFirstMile?: boolean;
|
||||||
|
hasLastMile?: boolean;
|
||||||
|
customerTruckAssignedAt?: string | null;
|
||||||
|
}, bookingId: string): Promise<void> {
|
||||||
|
if (!booking.companyId) return;
|
||||||
|
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||||
|
if (booking.customerTruckAssignedAt) return; // already assigned
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: 'Assign a truck for pickup',
|
||||||
|
body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`,
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch 6 — final terminal release / gate clearance.
|
* Batch 6 — final terminal release / gate clearance.
|
||||||
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||||||
@@ -866,7 +899,10 @@ export class WarehouseInventoryService {
|
|||||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||||
b.customer_truck_type AS "customerTruckType",
|
b.customer_truck_type AS "customerTruckType",
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
|
b.company_id AS "companyId",
|
||||||
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||||
|
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||||
@@ -1002,6 +1038,7 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
result.receivedCount += 1;
|
result.receivedCount += 1;
|
||||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||||
|
void this.notifyTruckAssignmentNeeded(booking, bookingId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { OnEvent } from "@nestjs/event-emitter";
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
import { Freight } from "@edr/types";
|
import { Freight, NotificationAudience, NotificationType } from "@edr/types";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BillingService,
|
BillingService,
|
||||||
InvoiceEventPayload,
|
InvoiceEventPayload,
|
||||||
@@ -135,6 +137,7 @@ export class WarehouseInvoiceService {
|
|||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
private readonly feeService: WarehouseFeeService,
|
private readonly feeService: WarehouseFeeService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Generation ───────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────
|
||||||
@@ -968,6 +971,25 @@ export class WarehouseInvoiceService {
|
|||||||
message,
|
message,
|
||||||
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// In-app deep-link to pay the fee from the booking.
|
||||||
|
if (invoice.customerId && invoice.bookingId) {
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: invoice.customerId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
title: "Warehouse fee due",
|
||||||
|
body:
|
||||||
|
`Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
|
||||||
|
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
|
||||||
|
link: `/bookings/${invoice.bookingId}`,
|
||||||
|
data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyWarehouseFeePayment(
|
private async notifyWarehouseFeePayment(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module';
|
|||||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
@@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
forwardRef(() => LastMileModule),
|
forwardRef(() => LastMileModule),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
Reference in New Issue
Block a user