mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
enhance booking and contract notification systems
- Added detailed logging for socket connection events in useBookingWindowSocket. - Introduced new notification types for contract status and schedule updates. - Updated notification visuals to include new icons for contract status. - Enhanced notification href resolution for contract status and schedule updates. - Implemented booking lifecycle notifier service for customer and staff notifications. - Created contract notifier service for managing contract lifecycle notifications. - Added end-to-end tests for booking window socket functionality.
This commit is contained in:
@@ -1517,6 +1517,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
"PREPAID",
|
||||
);
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
// Customer tracking: a wagon slot is reserved and the freight pay window is
|
||||
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
|
||||
void this.completeTrackingMilestones(booking.id, [
|
||||
"WAGON_REQUESTED",
|
||||
"FREIGHT_PAYMENT_PENDING",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
|
||||
@@ -1548,6 +1554,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.notifier.secured(booking, reason);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
void this.markWagonAllocatedMilestone(booking.id);
|
||||
// Customer tracking: freight payment settled (commercial pay-window path).
|
||||
// Government allocations don't pay upfront — theirs stay pending.
|
||||
if (reason === 'paid') {
|
||||
void this.completeTrackingMilestones(booking.id, [
|
||||
'WAGON_REQUESTED',
|
||||
'FREIGHT_PAYMENT_PENDING',
|
||||
'FREIGHT_PAYMENT_SETTLED',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
|
||||
@@ -1559,6 +1574,27 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete customer-tracking milestones on lifecycle events via the
|
||||
* doc-trigger path — a silent no-op for bookings without milestone rows
|
||||
* (non-customs bookings). Never blocks the batch action.
|
||||
*/
|
||||
private async completeTrackingMilestones(
|
||||
bookingId: string,
|
||||
codes: string[],
|
||||
): Promise<void> {
|
||||
if (!this.milestoneService) return;
|
||||
for (const code of codes) {
|
||||
try {
|
||||
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
NotifyInput,
|
||||
} from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
@Injectable()
|
||||
export class BookingNotifierService {
|
||||
private readonly logger = new Logger(BookingNotifierService.name);
|
||||
|
||||
constructor(private readonly notifications: NotificationsService) {}
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
@@ -41,11 +51,34 @@ export class BookingNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the booking's company. */
|
||||
private inApp(
|
||||
b: Booking,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.SCHEDULE_UPDATE,
|
||||
title,
|
||||
body,
|
||||
link: `/bookings/${b.id}`,
|
||||
data: { bookingId: b.id, reference: b.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW');
|
||||
this.inApp(b, 'Payment window open', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +99,9 @@ export class BookingNotifierService {
|
||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
|
||||
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||
this.inApp(b, 'Partial allocation offer', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||
@@ -73,11 +109,13 @@ export class BookingNotifierService {
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
this.inApp(b, 'Wagon allocated', msg);
|
||||
}
|
||||
|
||||
expired(b: Booking): void {
|
||||
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
|
||||
void this.notifyContact(b, msg, 'EXPIRED');
|
||||
this.inApp(b, 'Payment window expired', msg);
|
||||
}
|
||||
|
||||
scheduleFull(b: Booking): void {
|
||||
@@ -100,5 +138,42 @@ export class BookingNotifierService {
|
||||
displaced(b: Booking): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
this.inApp(b, 'Booking displaced', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff rescheduled the train carrying this booking to a new departure date.
|
||||
* The booking stays on the train — only the date moved.
|
||||
*/
|
||||
rescheduled(b: Booking, newDeparture: Date): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
|
||||
void this.notifyContact(b, msg, 'RESCHEDULED');
|
||||
this.inApp(b, 'Booking rescheduled', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking was removed from its train during a staff reschedule (not a government
|
||||
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
||||
*/
|
||||
removedFromTrain(b: Booking): void {
|
||||
const msg =
|
||||
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
|
||||
`Please rebook or select a new schedule from the portal.`;
|
||||
void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
|
||||
this.inApp(b, 'Removed from train', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* The train carrying this booking was moved for maintenance to a new departure
|
||||
* date. The booking stays on the train — only the date moved.
|
||||
*/
|
||||
maintenanceMoved(b: Booking, newDeparture: Date): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg =
|
||||
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
|
||||
`New departure date: ${when}.`;
|
||||
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
|
||||
this.inApp(b, 'Train maintenance reschedule', msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
/**
|
||||
* End-to-end proof the booking-window socket works: boots a real Nest app with
|
||||
* the gateway, connects a real socket.io client to the namespace, emits a phase
|
||||
* change, and asserts the client receives the exact payload. If this passes,
|
||||
* any "no live update" report is environmental (stale server process, wrong
|
||||
* checkout running, client not connecting) — not the gateway.
|
||||
*/
|
||||
describe('BookingWindowGateway (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let gateway: BookingWindowGateway;
|
||||
let client: Socket;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
BookingWindowGateway,
|
||||
// Accept any token — auth plumbing is covered by the real WsAuthService.
|
||||
{ provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.listen(0);
|
||||
const address = app.getHttpServer().address() as { port: number };
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
gateway = app.get(BookingWindowGateway);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
client?.disconnect();
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('authenticated client receives the phase event with the schedule state', async () => {
|
||||
client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
|
||||
auth: { token: 'any' },
|
||||
transports: ['websocket'],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on('connect', () => resolve());
|
||||
client.on('connect_error', (err) => reject(err));
|
||||
});
|
||||
|
||||
const received = new Promise<Record<string, unknown>>((resolve) => {
|
||||
client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload));
|
||||
});
|
||||
|
||||
gateway.emitPhase({
|
||||
id: 'sched-1',
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
direction: 'IMPORT',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
bookingCycleNo: 2,
|
||||
windowOpensAt: new Date('2026-07-06T16:15:00Z'),
|
||||
windowClosesAt: new Date('2026-07-06T16:18:00Z'),
|
||||
docReviewEndsAt: null,
|
||||
paymentPhaseEndsAt: null,
|
||||
scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'),
|
||||
} as unknown as TrainSchedule);
|
||||
|
||||
const payload = await received;
|
||||
expect(payload).toMatchObject({
|
||||
scheduleId: 'sched-1',
|
||||
phase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
bookingCycleNo: 2,
|
||||
windowOpensAt: '2026-07-06T16:15:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a client whose token does not resolve to a user', async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
BookingWindowGateway,
|
||||
{ provide: WsAuthService, useValue: { resolveUserId: async () => null } },
|
||||
],
|
||||
}).compile();
|
||||
const rejectingApp = moduleRef.createNestApplication();
|
||||
await rejectingApp.listen(0);
|
||||
const addr = rejectingApp.getHttpServer().address() as { port: number };
|
||||
|
||||
const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
|
||||
auth: { token: 'bad' },
|
||||
transports: ['websocket'],
|
||||
reconnection: false,
|
||||
});
|
||||
const outcome = await new Promise<string>((resolve) => {
|
||||
rejected.on('disconnect', () => resolve('disconnected'));
|
||||
rejected.on('connect_error', () => resolve('rejected'));
|
||||
// The server accepts the transport then drops it in handleConnection.
|
||||
setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500);
|
||||
});
|
||||
rejected.disconnect();
|
||||
await rejectingApp.close();
|
||||
expect(outcome).not.toBe('still-connected');
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,9 @@ export class BookingWindowGateway implements OnGatewayConnection {
|
||||
return;
|
||||
}
|
||||
socket.data.userId = userId;
|
||||
// Log at info so "is anyone actually connected?" is answerable from the
|
||||
// API log when diagnosing missing live updates.
|
||||
this.logger.log(`Booking-window client connected (user ${userId})`);
|
||||
}
|
||||
|
||||
/** Push a schedule's current window state to every connected client. */
|
||||
|
||||
@@ -2,12 +2,17 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
} from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||
@@ -41,6 +46,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly gateway: BookingWindowGateway,
|
||||
) {}
|
||||
|
||||
@@ -380,22 +386,26 @@ export class BookingWindowService implements OnModuleInit {
|
||||
*/
|
||||
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
|
||||
try {
|
||||
const rows: Array<{ phone: string | null; email: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT
|
||||
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
||||
COALESCE(co.email, co.general_manager_email) AS email
|
||||
FROM freight.contract_routes cr
|
||||
JOIN freight.contracts c
|
||||
ON c.id = cr.contract_id
|
||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||
AND c.deleted_at IS NULL
|
||||
JOIN freight.companies co ON co.id = c.company_id
|
||||
WHERE cr.origin_yard_id = $1
|
||||
AND cr.destination_yard_id = $2
|
||||
AND cr.deleted_at IS NULL`,
|
||||
[schedule.originStationId, schedule.destinationStationId],
|
||||
);
|
||||
const rows: Array<{
|
||||
company_id: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT DISTINCT
|
||||
c.company_id,
|
||||
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
||||
COALESCE(co.email, co.general_manager_email) AS email
|
||||
FROM freight.contract_routes cr
|
||||
JOIN freight.contracts c
|
||||
ON c.id = cr.contract_id
|
||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||
AND c.deleted_at IS NULL
|
||||
JOIN freight.companies co ON co.id = c.company_id
|
||||
WHERE cr.origin_yard_id = $1
|
||||
AND cr.destination_yard_id = $2
|
||||
AND cr.deleted_at IS NULL`,
|
||||
[schedule.originStationId, schedule.destinationStationId],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
|
||||
const closes = schedule.windowClosesAt
|
||||
@@ -410,6 +420,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
|
||||
const seenPhone = new Set<string>();
|
||||
const seenEmail = new Set<string>();
|
||||
const seenCompany = new Set<string>();
|
||||
for (const r of rows) {
|
||||
if (r.phone && !seenPhone.has(r.phone)) {
|
||||
seenPhone.add(r.phone);
|
||||
@@ -423,9 +434,23 @@ export class BookingWindowService implements OnModuleInit {
|
||||
.directSend('email', r.email, msg)
|
||||
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
|
||||
}
|
||||
// In-app inbox item for every portal user of each eligible company,
|
||||
// deep-linking to the new-booking page.
|
||||
if (r.company_id && !seenCompany.has(r.company_id)) {
|
||||
seenCompany.add(r.company_id);
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: r.company_id },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.SCHEDULE_UPDATE,
|
||||
title: 'Booking window open',
|
||||
body: msg,
|
||||
link: '/bookings/new',
|
||||
data: { trainScheduleId: schedule.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
|
||||
`Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -33,6 +33,7 @@ import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
|
||||
@Module({
|
||||
@@ -56,6 +57,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
forwardRef(() => BookingsModule),
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
@@ -76,6 +78,11 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
BookingSplitService,
|
||||
IntercityService,
|
||||
],
|
||||
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
|
||||
exports: [
|
||||
TrainSchedulingService,
|
||||
BookingBatchService,
|
||||
BookingWindowService,
|
||||
BookingNotifierService,
|
||||
],
|
||||
})
|
||||
export class TrainSchedulingModule {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
@@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
@@ -274,9 +276,45 @@ export class TrainSchedulingService {
|
||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Complete customer-tracking clearance milestones for every booking on a
|
||||
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||||
* unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
|
||||
* bookings without milestone rows (non-customs bookings), so this is safe to
|
||||
* call for every direction and flow. Never blocks the operational action.
|
||||
*/
|
||||
private async completeMilestonesForScheduleBookings(
|
||||
scheduleId: string,
|
||||
codes: string[],
|
||||
): Promise<void> {
|
||||
if (!this.milestoneService || codes.length === 0) return;
|
||||
try {
|
||||
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
|
||||
`SELECT tsb.booking_id
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
for (const { booking_id } of rows) {
|
||||
for (const code of codes) {
|
||||
await this.milestoneService.completeByDocTrigger(
|
||||
{ bookingId: booking_id },
|
||||
code,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a schedule's current booking-window state over the socket so the
|
||||
* portal home card and backoffice GL/batch views update in real time —
|
||||
@@ -1121,26 +1159,32 @@ export class TrainSchedulingService {
|
||||
{ country: schedule.destinationCountry },
|
||||
);
|
||||
if (direction === 'IMPORT') {
|
||||
const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
||||
scheduleId,
|
||||
'SYSTEM_TRAIN_ARRIVAL',
|
||||
);
|
||||
// Customer tracking: cargo is off the train at the destination yard.
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
|
||||
return {
|
||||
direction,
|
||||
action: 'IMPORT_AUTO_UNLOAD',
|
||||
status: 'COMPLETED',
|
||||
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
||||
scheduleId,
|
||||
'SYSTEM_TRAIN_ARRIVAL',
|
||||
),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
||||
const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
||||
scheduleId,
|
||||
'SYSTEM_TRAIN_ARRIVAL',
|
||||
);
|
||||
// Customer tracking: cargo is off the train at the Djibouti port.
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
|
||||
return {
|
||||
direction,
|
||||
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
||||
status: 'COMPLETED',
|
||||
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
||||
scheduleId,
|
||||
'SYSTEM_TRAIN_ARRIVAL',
|
||||
),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1443,6 +1487,20 @@ export class TrainSchedulingService {
|
||||
|
||||
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||||
void this.emitWindowState(scheduleId);
|
||||
// Customer tracking: cargo is on the departing train — loading milestones
|
||||
// plus the direction's "departed" handoff milestone.
|
||||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||
// doc-trigger path no-ops it for import bookings.
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
schedule.direction === 'IMPORT'
|
||||
? 'DEPARTED_FROM_DJIBOUTI'
|
||||
: 'DEPARTED_TO_DJIBOUTI',
|
||||
]);
|
||||
}
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
@@ -1599,6 +1657,13 @@ export class TrainSchedulingService {
|
||||
LoadingStatus.Loaded,
|
||||
);
|
||||
}
|
||||
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
|
||||
// is the export-side "cargo reached origin yard" step that precedes it).
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
]);
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
@@ -2417,6 +2482,13 @@ export class TrainSchedulingService {
|
||||
}
|
||||
});
|
||||
|
||||
// Customer tracking: the train reached the corridor's far end.
|
||||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
|
||||
]);
|
||||
}
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||
return Object.assign(detail, { warehouseAutomation });
|
||||
|
||||
Reference in New Issue
Block a user