mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +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:
@@ -93,6 +93,7 @@
|
|||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/vorpal": "^1.12.8",
|
"@types/vorpal": "^1.12.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
|||||||
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||||
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
|
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
|
||||||
|
|
||||||
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
|
// Subpath imports (not the package root) so ts-jest can resolve them when this
|
||||||
|
// file lands in a spec's compile graph via the notification recipients chain.
|
||||||
|
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
|
||||||
|
import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity";
|
||||||
|
import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity";
|
||||||
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
|
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
|
||||||
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
|
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
|
||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
@@ -40,6 +44,21 @@ export class BackofficeService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAM user ids of every current employee across all organizations — used by
|
||||||
|
* the notification recipients resolver's `allBackoffice` selector.
|
||||||
|
*/
|
||||||
|
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
|
||||||
|
const employees = await this.employeeRepository.find({
|
||||||
|
where: { isCurrent: true },
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
employees.map((e) => e.userId).filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
async createOrganizationUser(
|
async createOrganizationUser(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
dto: CreateOrganizationUserDto,
|
dto: CreateOrganizationUserDto,
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationType,
|
||||||
|
NotifyInput,
|
||||||
|
} from '@edr/types';
|
||||||
|
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer + staff notifications for the booking lifecycle: review, clearance
|
||||||
|
* and operation flow. Every customer event fans out over SMS + email (direct)
|
||||||
|
* and a persisted in-app notification deep-linking to the booking detail page;
|
||||||
|
* staff events land in the backoffice inbox. All sends are fire-and-forget and
|
||||||
|
* never throw — a notification failure must not break a booking transition.
|
||||||
|
*
|
||||||
|
* NOTE: the batch/payment-window notifications (pay-now, allocated, expired,
|
||||||
|
* displaced) are handled separately by {@link BookingNotifierService} in
|
||||||
|
* train-scheduling.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingLifecycleNotifierService {
|
||||||
|
private readonly logger = new Logger(BookingLifecycleNotifierService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private ref(b: Booking): string {
|
||||||
|
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send SMS + email to the booking's company contact; log-only on failure. */
|
||||||
|
private async notifyContact(
|
||||||
|
b: Booking,
|
||||||
|
message: string,
|
||||||
|
logLabel: string,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||||
|
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
|
||||||
|
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend('sms', phone, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend('email', email, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!phone && !email) {
|
||||||
|
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.BOOKING_STATUS,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: `/bookings/${b.id}`,
|
||||||
|
data: { bookingId: b.id, reference: b.reference },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist + push an in-app item to every backoffice staff user. */
|
||||||
|
private inAppStaff(
|
||||||
|
b: Booking,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
overrides: Partial<NotifyInput> = {},
|
||||||
|
): void {
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { allBackoffice: true },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: `/dashboard/booking-requests/${b.id}`,
|
||||||
|
data: { bookingId: b.id, reference: b.reference },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-facing lifecycle events ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Line staff accepted intake → booking is under approval. */
|
||||||
|
accepted(b: Booking): void {
|
||||||
|
const msg =
|
||||||
|
`Your booking ${b.reference} has been accepted and is now under approval. ` +
|
||||||
|
`We will notify you once it is approved.`;
|
||||||
|
void this.notifyContact(b, msg, 'ACCEPTED');
|
||||||
|
this.inApp(b, 'Booking accepted', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All approval steps complete → contract generated, ready for customer to sign. */
|
||||||
|
approved(b: Booking): void {
|
||||||
|
const msg =
|
||||||
|
`Your booking ${b.reference} has been approved. ` +
|
||||||
|
`Please review and sign your contract from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'APPROVED');
|
||||||
|
this.inApp(b, 'Booking approved', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff rejected the booking (intake or approval step). */
|
||||||
|
rejected(b: Booking, reason: string): void {
|
||||||
|
const msg =
|
||||||
|
`Your booking ${b.reference} was rejected. Reason: ${reason}. ` +
|
||||||
|
`Please contact us for details.`;
|
||||||
|
void this.notifyContact(b, msg, 'REJECTED');
|
||||||
|
this.inApp(b, 'Booking rejected', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff requested changes before approval. */
|
||||||
|
changesRequested(b: Booking, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`Changes were requested on your booking ${b.reference}: ${note}. ` +
|
||||||
|
`Please update and resubmit from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'CHANGES REQUESTED');
|
||||||
|
this.inApp(b, 'Booking changes requested', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A clearance document was queried and needs the customer to re-upload. */
|
||||||
|
documentQueried(b: Booking, fileKey: string, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
|
||||||
|
`${note}. Please re-upload from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'DOCUMENT QUERIED');
|
||||||
|
this.inApp(b, 'Document queried', msg, {
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clearance finalized → customer can proceed to request operation. */
|
||||||
|
clearanceReady(b: Booking): void {
|
||||||
|
const msg =
|
||||||
|
`Clearance for booking ${b.reference} is complete. ` +
|
||||||
|
`You can now proceed to request operation from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'CLEARANCE READY');
|
||||||
|
this.inApp(b, 'Clearance complete', msg, {
|
||||||
|
type: NotificationType.CLEARANCE_DECISION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operations returned the operation request for changes. */
|
||||||
|
operationChangesRequested(b: Booking, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
||||||
|
`Please update and resubmit from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED');
|
||||||
|
this.inApp(b, 'Operation request needs changes', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operation accepted → invoice ready; await payment / booking window. */
|
||||||
|
operationAccepted(b: Booking): void {
|
||||||
|
const msg =
|
||||||
|
`Your operation request for booking ${b.reference} has been accepted. ` +
|
||||||
|
`An invoice has been prepared — watch for the payment window to secure your slot.`;
|
||||||
|
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
|
||||||
|
this.inApp(b, 'Operation request accepted', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shipment started → in transit. */
|
||||||
|
inTransit(b: Booking): void {
|
||||||
|
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
|
||||||
|
void this.notifyContact(b, msg, 'IN TRANSIT');
|
||||||
|
this.inApp(b, 'Shipment in transit', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shipment delivered → completed. */
|
||||||
|
completed(b: Booking): void {
|
||||||
|
const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`;
|
||||||
|
void this.notifyContact(b, msg, 'COMPLETED');
|
||||||
|
this.inApp(b, 'Shipment delivered', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Booking cancelled. */
|
||||||
|
cancelled(b: Booking, reason: string): void {
|
||||||
|
const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`;
|
||||||
|
void this.notifyContact(b, msg, 'CANCELLED');
|
||||||
|
this.inApp(b, 'Booking cancelled', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clearance milestones needing customer action ──────────────────────────
|
||||||
|
|
||||||
|
/** GL advised duty & tax — the customer must pay and upload the slip. */
|
||||||
|
dutyAdvised(b: Booking, amount: number, currency: string): void {
|
||||||
|
const msg =
|
||||||
|
`Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
|
||||||
|
`Please pay and upload the payment slip from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'DUTY ADVISED');
|
||||||
|
this.inApp(b, 'Duty & tax advised', msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL advised the post-arrival additional duty round (import). */
|
||||||
|
secondDutyAdvised(b: Booking, amount: number, currency: string): void {
|
||||||
|
const msg =
|
||||||
|
`Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
|
||||||
|
`Please pay and upload the payment slip from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'SECOND DUTY ADVISED');
|
||||||
|
this.inApp(b, 'Additional duty & tax advised', msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
|
||||||
|
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
|
||||||
|
const msg =
|
||||||
|
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
|
||||||
|
`Please pay and upload the payment slip from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'FINAL INVOICE');
|
||||||
|
this.inApp(b, 'Final invoice issued', msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL confirmed the final-invoice payment slip. */
|
||||||
|
finalInvoicePaid(b: Booking): void {
|
||||||
|
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
|
||||||
|
void this.notifyContact(b, msg, 'FINAL INVOICE PAID');
|
||||||
|
this.inApp(b, 'Final invoice paid', msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Customer submitted a booking for review. */
|
||||||
|
submittedToStaff(b: Booking): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'New booking submitted',
|
||||||
|
`Booking ${this.ref(b)} was submitted and is awaiting intake review.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer signed the booking contract. */
|
||||||
|
customerSignedToStaff(b: Booking): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'Customer signed booking contract',
|
||||||
|
`The contract for booking ${this.ref(b)} was signed by the customer.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer requested operation (picked a shipment day). */
|
||||||
|
operationRequestedToStaff(b: Booking): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'Operation requested',
|
||||||
|
`Booking ${this.ref(b)} requested operation — review capacity, documents and route.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer uploaded clearance documents — review is next. */
|
||||||
|
clearanceDocsUploadedToStaff(b: Booking): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'Clearance documents uploaded',
|
||||||
|
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
|
||||||
|
{
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
|
||||||
|
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
|
||||||
|
const label =
|
||||||
|
round === 'final'
|
||||||
|
? 'final invoice'
|
||||||
|
: round === 'second'
|
||||||
|
? 'additional duty & tax'
|
||||||
|
: 'duty & tax';
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'Payment slip uploaded',
|
||||||
|
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
|
||||||
|
{
|
||||||
|
type: NotificationType.PAYMENT_RECEIVED,
|
||||||
|
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
ruleEngineService as never,
|
ruleEngineService as never,
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
{} as never, // invoiceService
|
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService };
|
return { service, bookingsRepository, ruleEngineService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
{} as never, // invoiceService
|
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // invoiceService
|
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // invoiceService
|
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Operation-request review for general-contract drawdown orders:
|
* Operation-request review for general-contract drawdown orders:
|
||||||
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
* - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/
|
||||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
* domestic bookings wait for their booking-day window cycle (no immediate
|
||||||
|
* batch enqueue at accept time).
|
||||||
|
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch.
|
||||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||||
*/
|
*/
|
||||||
describe('BookingTransitionService — operation review', () => {
|
describe('BookingTransitionService — operation review', () => {
|
||||||
function makeService(serviceTypeCode: string) {
|
function makeService(serviceTypeCode: string) {
|
||||||
const booking = {
|
const booking = {
|
||||||
id: 'b-1',
|
id: 'b-1',
|
||||||
|
reference: 'BKG-1',
|
||||||
status: 'OPERATION_REQUEST_PENDING',
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
originYardId: 'o-1',
|
originYardId: 'o-1',
|
||||||
destinationYardId: 'd-1',
|
destinationYardId: 'd-1',
|
||||||
@@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
};
|
};
|
||||||
const bookingBatchService = {
|
const bookingBatchService = {
|
||||||
enqueueRouteDayProcessing: jest.fn(),
|
enqueueRouteDayProcessing: jest.fn(),
|
||||||
|
pickExportSchedule: jest.fn(),
|
||||||
|
acceptExportBooking: jest.fn(),
|
||||||
|
};
|
||||||
|
const invoiceService = {
|
||||||
|
ensureInvoiceForBooking: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }),
|
||||||
|
updateStatus: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new BookingTransitionService(
|
const service = new BookingTransitionService(
|
||||||
@@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
{} as never, // invoiceService
|
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
invoiceService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
|
it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => {
|
||||||
const { service, bookingsRepository, bookingBatchService } =
|
const { service, bookingsRepository, bookingBatchService, invoiceService } =
|
||||||
makeService('RAIL_CONTAINER');
|
makeService('RAIL_CONTAINER');
|
||||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
'b-1',
|
'b-1',
|
||||||
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
||||||
);
|
);
|
||||||
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
|
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||||
|
// Import/domestic train bookings are batched by the window cycle later —
|
||||||
|
// never enqueued directly at accept time.
|
||||||
|
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
||||||
|
expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
|
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => {
|
||||||
const { service, bookingsRepository, bookingBatchService } =
|
const { service, bookingsRepository, bookingBatchService, invoiceService } =
|
||||||
makeService('ROAD_CONTAINER');
|
makeService('ROAD_CONTAINER');
|
||||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
'b-1',
|
'b-1',
|
||||||
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
|
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
|
||||||
);
|
);
|
||||||
|
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||||
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
|
Optional,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
|||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
|
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { ContainerValidationService } from './container-validation.service';
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
@@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
|||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||||
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||||
import { ContractDocPhase } from '@edr/types';
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
@@ -53,7 +56,8 @@ export class BookingTransitionService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
||||||
@@ -124,6 +128,9 @@ export class BookingTransitionService {
|
|||||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||||
updated!.id,
|
updated!.id,
|
||||||
);
|
);
|
||||||
|
if (finalBooking.status === "SUBMITTED") {
|
||||||
|
this.notifier.submittedToStaff(finalBooking);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
bookingId: finalBooking.id,
|
bookingId: finalBooking.id,
|
||||||
status: finalBooking.status,
|
status: finalBooking.status,
|
||||||
@@ -204,6 +211,9 @@ export class BookingTransitionService {
|
|||||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||||
updated!.id,
|
updated!.id,
|
||||||
);
|
);
|
||||||
|
if (finalBooking.status === "SUBMITTED") {
|
||||||
|
this.notifier.submittedToStaff(finalBooking);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
bookingId: finalBooking.id,
|
bookingId: finalBooking.id,
|
||||||
status: finalBooking.status,
|
status: finalBooking.status,
|
||||||
@@ -233,7 +243,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CHANGES_REQUESTED",
|
status: "CHANGES_REQUESTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.changesRequested(fresh, note);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-create booking approval steps from system rules when none exist yet. */
|
/** Auto-create booking approval steps from system rules when none exist yet. */
|
||||||
@@ -284,7 +296,9 @@ export class BookingTransitionService {
|
|||||||
contractValidFrom: validFrom,
|
contractValidFrom: validFrom,
|
||||||
contractValidUntil: validUntil,
|
contractValidUntil: validUntil,
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.accepted(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async staffReject(
|
async staffReject(
|
||||||
@@ -305,7 +319,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "REJECTED",
|
status: "REJECTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.rejected(fresh, reason);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveStep(
|
async approveStep(
|
||||||
@@ -394,7 +410,9 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
const generated = await this.contractService.generateContract(bookingId);
|
const generated = await this.contractService.generateContract(bookingId);
|
||||||
return this.bookingsService.findById(generated.id);
|
const fresh = await this.bookingsService.findById(generated.id);
|
||||||
|
this.notifier.approved(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -435,7 +453,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "REJECTED",
|
status: "REJECTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.rejected(fresh, reason);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async customerSign(bookingId: string): Promise<Booking> {
|
async customerSign(bookingId: string): Promise<Booking> {
|
||||||
@@ -446,7 +466,9 @@ export class BookingTransitionService {
|
|||||||
status: "SIGNED_CUSTOMER",
|
status: "SIGNED_CUSTOMER",
|
||||||
customerSignedAt: new Date(),
|
customerSignedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.customerSignedToStaff(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startTransit(bookingId: string): Promise<Booking> {
|
async startTransit(bookingId: string): Promise<Booking> {
|
||||||
@@ -456,7 +478,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "IN_TRANSIT",
|
status: "IN_TRANSIT",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.inTransit(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(bookingId: string): Promise<Booking> {
|
async complete(bookingId: string): Promise<Booking> {
|
||||||
@@ -467,7 +491,28 @@ export class BookingTransitionService {
|
|||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
endDate: new Date(),
|
endDate: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.completed(fresh);
|
||||||
|
// Customer tracking: close out the tail milestones so a finished shipment
|
||||||
|
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
|
||||||
|
// implied by delivery; a storage invoice that was never raised is skipped
|
||||||
|
// (storage billing does not apply to every shipment). All doc-trigger /
|
||||||
|
// best-effort — a booking without milestone rows is untouched.
|
||||||
|
if (this.milestoneService) {
|
||||||
|
for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) {
|
||||||
|
try {
|
||||||
|
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
|
||||||
|
} catch {
|
||||||
|
/* tracking must never block completion */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED");
|
||||||
|
} catch {
|
||||||
|
/* no such milestone row (export / non-customs) — fine */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||||
@@ -491,7 +536,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CANCELLED",
|
status: "CANCELLED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.cancelled(fresh, reason);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -723,7 +770,9 @@ export class BookingTransitionService {
|
|||||||
} as never);
|
} as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.clearanceDocsUploadedToStaff(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -824,6 +873,9 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.bookingsService.findById(bookingId);
|
const updated = await this.bookingsService.findById(bookingId);
|
||||||
|
if (status === "QUERIED") {
|
||||||
|
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
||||||
|
}
|
||||||
if (this.isPhasedGeneralCustoms(updated)) {
|
if (this.isPhasedGeneralCustoms(updated)) {
|
||||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||||
if (allApproved) {
|
if (allApproved) {
|
||||||
@@ -912,7 +964,9 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CLEARANCE_READY",
|
status: "CLEARANCE_READY",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.clearanceReady(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -957,7 +1011,9 @@ export class BookingTransitionService {
|
|||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.operationRequestedToStaff(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -992,7 +1048,9 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "OPERATION_CHANGES_REQUESTED",
|
status: "OPERATION_CHANGES_REQUESTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.operationChangesRequested(fresh, options.note);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACCEPT — enter the batch holding pool.
|
// ACCEPT — enter the batch holding pool.
|
||||||
@@ -1037,7 +1095,9 @@ export class BookingTransitionService {
|
|||||||
fullyExecutedAt: now,
|
fullyExecutedAt: now,
|
||||||
lockedAt: booking.lockedAt ?? now,
|
lockedAt: booking.lockedAt ?? now,
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(booking.id);
|
const roadFresh = await this.bookingsService.findById(booking.id);
|
||||||
|
this.notifier.operationAccepted(roadFresh);
|
||||||
|
return roadFresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
@@ -1072,7 +1132,9 @@ export class BookingTransitionService {
|
|||||||
// batch runs after the window closes + staff document review, never at accept
|
// batch runs after the window closes + staff document review, never at accept
|
||||||
// time. (Legacy pre-migration schedules with no window phase are still served
|
// time. (Legacy pre-migration schedules with no window phase are still served
|
||||||
// by the periodic legacy fill.)
|
// by the periodic legacy fill.)
|
||||||
return this.bookingsService.findById(booking.id);
|
const trainFresh = await this.bookingsService.findById(booking.id);
|
||||||
|
this.notifier.operationAccepted(trainFresh);
|
||||||
|
return trainFresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichBookingResponse(booking: Booking): Promise<
|
async enrichBookingResponse(booking: Booking): Promise<
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service';
|
|||||||
// import { BookingPaymentService } from './booking-payment.service';
|
// import { BookingPaymentService } from './booking-payment.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
|
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||||
import { BookingTransitionService } from './booking-transition.service';
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
// import { PayController } from './pay.controller';
|
// import { PayController } from './pay.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
@@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
CustomerTruckContainer,
|
CustomerTruckContainer,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
forwardRef(() => ContractsModule),
|
forwardRef(() => ContractsModule),
|
||||||
@@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
ContainerValidationService,
|
ContainerValidationService,
|
||||||
BookingReferenceDataService,
|
BookingReferenceDataService,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
|
BookingLifecycleNotifierService,
|
||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
BookingContractService,
|
BookingContractService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
@@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
|
BookingLifecycleNotifierService,
|
||||||
CustomerTruckService,
|
CustomerTruckService,
|
||||||
ContainerReceiptService,
|
ContainerReceiptService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1388,6 +1388,17 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface the assigned train's operational status so the portal stepper
|
||||||
|
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||||
|
// dispatch until delivery, so arrival is only knowable from the schedule.
|
||||||
|
if (booking.trainScheduleId) {
|
||||||
|
const schedule = await this.dataSource
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.findOne({ where: { id: booking.trainScheduleId } });
|
||||||
|
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
|
||||||
|
schedule?.status ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ function makeService(overrides?: {
|
|||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
dropdownSettingsService as never,
|
dropdownSettingsService as never,
|
||||||
glOperationsService as never,
|
glOperationsService as never,
|
||||||
|
{
|
||||||
|
dutyAdvised: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -115,12 +122,18 @@ describe('BookingClearanceService', () => {
|
|||||||
|
|
||||||
it('records duty advice when duty applies', async () => {
|
it('records duty advice when duty applies', async () => {
|
||||||
const { service, milestoneService } = makeService();
|
const { service, milestoneService } = makeService();
|
||||||
await service.adviseDuty('b-general', {
|
await service.adviseDuty(
|
||||||
dutyRequired: true,
|
'b-general',
|
||||||
amount: 1500,
|
{
|
||||||
currency: 'ETB',
|
dutyRequired: true,
|
||||||
declarationSerial: 'DS-1',
|
amount: 1500,
|
||||||
});
|
currency: 'ETB',
|
||||||
|
declarationSerial: 'DS-1',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
// The duty notice attachment is now mandatory when duty applies.
|
||||||
|
{ fieldname: 'duty_tax_notice' } as Express.Multer.File,
|
||||||
|
);
|
||||||
|
|
||||||
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
|
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
|
||||||
'b-general',
|
'b-general',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
|
|||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||||
@@ -100,6 +101,7 @@ export class BookingClearanceService {
|
|||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
private readonly glOperationsService: GlOperationsService,
|
private readonly glOperationsService: GlOperationsService,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
||||||
@@ -414,6 +416,7 @@ export class BookingClearanceService {
|
|||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -441,6 +444,7 @@ export class BookingClearanceService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
this.notifier.dutySlipUploadedToStaff(booking, 'first');
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { Freight } from '@edr/types';
|
|||||||
import { BookingRequestRepository } from './booking-request.repository';
|
import { BookingRequestRepository } from './booking-request.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { BookingRequest } from './entities/booking-request.entity';
|
import { BookingRequest } from './entities/booking-request.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
|
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
|
||||||
@@ -26,6 +27,7 @@ export class BookingRequestService {
|
|||||||
private readonly repo: BookingRequestRepository,
|
private readonly repo: BookingRequestRepository,
|
||||||
private readonly contractsService: ContractsService,
|
private readonly contractsService: ContractsService,
|
||||||
private readonly contractBookingService: ContractBookingService,
|
private readonly contractBookingService: ContractBookingService,
|
||||||
|
private readonly notifier: ContractNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
||||||
@@ -107,7 +109,7 @@ export class BookingRequestService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reference = await this.generateReference();
|
const reference = await this.generateReference();
|
||||||
return this.repo.create({
|
const request = await this.repo.create({
|
||||||
reference,
|
reference,
|
||||||
contractId,
|
contractId,
|
||||||
requestedByUserId: userId ?? null,
|
requestedByUserId: userId ?? null,
|
||||||
@@ -117,6 +119,8 @@ export class BookingRequestService {
|
|||||||
requestedLines,
|
requestedLines,
|
||||||
notes: dto.notes ?? null,
|
notes: dto.notes ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
|
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
|
||||||
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
listForContract(contractId: string): Promise<BookingRequest[]> {
|
listForContract(contractId: string): Promise<BookingRequest[]> {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) {
|
|||||||
contractsRepository as never,
|
contractsRepository as never,
|
||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
bookingsRepository as never,
|
bookingsRepository as never,
|
||||||
|
{ clearanceReady: jest.fn() } as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, milestoneService, contractsRepository, bookingsRepository };
|
return { service, milestoneService, contractsRepository, bookingsRepository };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import type { ClearanceMetaState } from './clearance-workflow.types';
|
import type { ClearanceMetaState } from './clearance-workflow.types';
|
||||||
import { metaFromBooking } from './clearance-workflow.types';
|
import { metaFromBooking } from './clearance-workflow.types';
|
||||||
@@ -34,6 +35,7 @@ export class ClearanceWorkflowService {
|
|||||||
private readonly contractsRepository: ContractsRepository,
|
private readonly contractsRepository: ContractsRepository,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
boundaryMilestone(tradeDirection: string): string {
|
boundaryMilestone(tradeDirection: string): string {
|
||||||
@@ -264,6 +266,14 @@ export class ClearanceWorkflowService {
|
|||||||
status: 'CLEARANCE_READY',
|
status: 'CLEARANCE_READY',
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
} as never);
|
} as never);
|
||||||
|
// Tell the customer clearance is done and operation can be requested. Load
|
||||||
|
// failure only skips the notice — the status change above already committed.
|
||||||
|
try {
|
||||||
|
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||||
|
if (booking) this.notifier.clearanceReady(booking);
|
||||||
|
} catch {
|
||||||
|
/* notification is best-effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvePhase(
|
resolvePhase(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
|
|||||||
import { contractClearanceCodes } from './contract-clearance.util';
|
import { contractClearanceCodes } from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { GlOperationsService } from './gl-operations.service';
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
@@ -118,6 +119,7 @@ export class ContractClearanceService {
|
|||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
private readonly glOperationsService: GlOperationsService,
|
private readonly glOperationsService: GlOperationsService,
|
||||||
|
private readonly notifier: ContractNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private isPhasedCustoms(contract: Contract): boolean {
|
private isPhasedCustoms(contract: Contract): boolean {
|
||||||
@@ -543,7 +545,9 @@ export class ContractClearanceService {
|
|||||||
await this.workflowService.onDocumentReviewReopened(contractId);
|
await this.workflowService.onDocumentReviewReopened(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.clearanceDocsUploadedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertRequiredInputsPresent(
|
private async assertRequiredInputsPresent(
|
||||||
@@ -674,6 +678,7 @@ export class ContractClearanceService {
|
|||||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||||
} as never);
|
} as never);
|
||||||
|
this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||||
}
|
}
|
||||||
@@ -1009,6 +1014,7 @@ export class ContractClearanceService {
|
|||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
@@ -1045,7 +1051,9 @@ export class ContractClearanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.dutySlipUploadedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadTransitPermit(
|
async uploadTransitPermit(
|
||||||
@@ -1118,6 +1126,7 @@ export class ContractClearanceService {
|
|||||||
await this.workflowService.markReadyForBooking(contractId);
|
await this.workflowService.markReadyForBooking(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.notifier.preClearanceFinalized(contract);
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationType,
|
||||||
|
NotifyInput,
|
||||||
|
} from '@edr/types';
|
||||||
|
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||||
|
* event fans out over three channels: SMS + email (direct, via
|
||||||
|
* {@link NotificationsService}) and a persisted in-app notification (via
|
||||||
|
* {@link NotificationInboxService}) that deep-links to the contract detail page.
|
||||||
|
* Staff events go to the backoffice inbox. All sends are fire-and-forget and
|
||||||
|
* never throw — a notification failure must not break a contract transition.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContractNotifierService {
|
||||||
|
private readonly logger = new Logger(ContractNotifierService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private ref(c: Contract): string {
|
||||||
|
return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send SMS + email to the contract's company contact; log-only on failure. */
|
||||||
|
private async notifyContact(
|
||||||
|
c: Contract,
|
||||||
|
message: string,
|
||||||
|
logLabel: string,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||||
|
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
|
||||||
|
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend('sms', phone, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend('email', email, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!phone && !email) {
|
||||||
|
this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist + push an in-app item to all portal users of the contract's company. */
|
||||||
|
private inApp(
|
||||||
|
c: Contract,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
overrides: Partial<NotifyInput> = {},
|
||||||
|
): void {
|
||||||
|
if (!c.companyId) return; // government/unlinked contracts have no portal users
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { companyId: c.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.CONTRACT_STATUS,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: `/contracts/${c.id}`,
|
||||||
|
data: { contractId: c.id, reference: c.reference },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist + push an in-app item to every backoffice staff user. */
|
||||||
|
private inAppStaff(
|
||||||
|
c: Contract,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
overrides: Partial<NotifyInput> = {},
|
||||||
|
): void {
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { allBackoffice: true },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: `/dashboard/contract-requests/${c.id}`,
|
||||||
|
data: { contractId: c.id, reference: c.reference },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-facing lifecycle events ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Line staff accepted intake → contract is under approval. */
|
||||||
|
accepted(c: Contract): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been accepted and is now under approval. ` +
|
||||||
|
`We will notify you once it is approved.`;
|
||||||
|
void this.notifyContact(c, msg, 'ACCEPTED');
|
||||||
|
this.inApp(c, 'Contract accepted', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All approval steps complete → contract approved. */
|
||||||
|
approved(c: Contract): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been approved. ` +
|
||||||
|
`The final document will be prepared for signing.`;
|
||||||
|
void this.notifyContact(c, msg, 'APPROVED');
|
||||||
|
this.inApp(c, 'Contract approved', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fully executed (all parties signed) → contract active, customer can book. */
|
||||||
|
signedActive(c: Contract): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been signed and is now active. ` +
|
||||||
|
`You can start booking shipments from the portal.`;
|
||||||
|
void this.notifyContact(c, msg, 'SIGNED / ACTIVE');
|
||||||
|
this.inApp(c, 'Contract active', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff rejected the contract. */
|
||||||
|
rejected(c: Contract, reason: string): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} was rejected. Reason: ${reason}. ` +
|
||||||
|
`Please contact us for details.`;
|
||||||
|
void this.notifyContact(c, msg, 'REJECTED');
|
||||||
|
this.inApp(c, 'Contract rejected', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff requested changes before approval. */
|
||||||
|
changesRequested(c: Contract, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`Changes were requested on your contract ${c.reference}: ${note}. ` +
|
||||||
|
`Please update and resubmit from the portal.`;
|
||||||
|
void this.notifyContact(c, msg, 'CHANGES REQUESTED');
|
||||||
|
this.inApp(c, 'Contract changes requested', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clearance milestones needing customer action ──────────────────────────
|
||||||
|
|
||||||
|
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||||
|
dutyAdvised(c: Contract, amount: number, currency: string): void {
|
||||||
|
const msg =
|
||||||
|
`Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` +
|
||||||
|
`Please pay and upload the payment slip from the portal.`;
|
||||||
|
void this.notifyContact(c, msg, 'DUTY ADVISED');
|
||||||
|
this.inApp(c, 'Duty & tax advised', msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
link: `/contracts/${c.id}/clearance`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A clearance document was queried — customer must re-upload it. */
|
||||||
|
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` +
|
||||||
|
`${note}. Please re-upload from the portal.`;
|
||||||
|
void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED');
|
||||||
|
this.inApp(c, 'Clearance document queried', msg, {
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
link: `/contracts/${c.id}/clearance`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import pre-clearance finalized — the process moves to GL Djibouti collection. */
|
||||||
|
preClearanceFinalized(c: Contract): void {
|
||||||
|
const msg =
|
||||||
|
`Pre-clearance for contract ${c.reference} is complete. ` +
|
||||||
|
`Your shipment is proceeding to document collection in Djibouti.`;
|
||||||
|
void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED');
|
||||||
|
this.inApp(c, 'Pre-clearance complete', msg, {
|
||||||
|
type: NotificationType.CLEARANCE_DECISION,
|
||||||
|
link: `/contracts/${c.id}/clearance`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Customer submitted a contract for review. */
|
||||||
|
submittedToStaff(c: Contract): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
c,
|
||||||
|
'New contract submitted',
|
||||||
|
`Contract ${this.ref(c)} was submitted and is awaiting intake review.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer signed the contract — staff counter-sign is next. */
|
||||||
|
customerSignedToStaff(c: Contract): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
c,
|
||||||
|
'Customer signed contract',
|
||||||
|
`Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`,
|
||||||
|
{ link: `/dashboard/contract-requests/${c.id}/view` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer uploaded clearance documents — GL review is next. */
|
||||||
|
clearanceDocsUploadedToStaff(c: Contract): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
c,
|
||||||
|
'Clearance documents uploaded',
|
||||||
|
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
|
||||||
|
{
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer uploaded the duty/tax payment slip — GL verifies it. */
|
||||||
|
dutySlipUploadedToStaff(c: Contract): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
c,
|
||||||
|
'Duty slip uploaded',
|
||||||
|
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
|
||||||
|
{
|
||||||
|
type: NotificationType.PAYMENT_RECEIVED,
|
||||||
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer filed a shipment request under a GENERAL customs contract. */
|
||||||
|
shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
c,
|
||||||
|
'New shipment request',
|
||||||
|
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
|
||||||
|
{
|
||||||
|
link: `/dashboard/shipment-requests/${requestId}`,
|
||||||
|
data: { contractId: c.id, requestId, reference: requestRef },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service';
|
|||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { OtpService } from '../otp/otp.service';
|
import { OtpService } from '../otp/otp.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
@@ -66,6 +67,7 @@ export class ContractTransitionService {
|
|||||||
private readonly pdfService: ContractPdfService,
|
private readonly pdfService: ContractPdfService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
|
private readonly notifier: ContractNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -79,7 +81,9 @@ export class ContractTransitionService {
|
|||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.submittedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
||||||
@@ -93,7 +97,9 @@ export class ContractTransitionService {
|
|||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.submittedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +136,9 @@ export class ContractTransitionService {
|
|||||||
contractValidFrom: validFrom,
|
contractValidFrom: validFrom,
|
||||||
contractValidUntil: validUntil,
|
contractValidUntil: validUntil,
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.accepted(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,7 +226,9 @@ export class ContractTransitionService {
|
|||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'CHANGES_REQUESTED',
|
status: 'CHANGES_REQUESTED',
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.changesRequested(updated, note);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
||||||
@@ -235,7 +245,9 @@ export class ContractTransitionService {
|
|||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.rejected(updated, reason);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
||||||
@@ -297,7 +309,11 @@ export class ContractTransitionService {
|
|||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
await this.contractsRepository.update(contractId, updates as never);
|
await this.contractsRepository.update(contractId, updates as never);
|
||||||
}
|
}
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
if (allDone) {
|
||||||
|
this.notifier.approved(updated);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -535,7 +551,9 @@ export class ContractTransitionService {
|
|||||||
customerSignedAt: new Date(),
|
customerSignedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
await this.regenerateContractPdf(contractId, contract.reference);
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.customerSignedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.counterSign(contractId, dto, options);
|
return this.counterSign(contractId, dto, options);
|
||||||
@@ -605,7 +623,9 @@ export class ContractTransitionService {
|
|||||||
|
|
||||||
await this.contractsRepository.update(contractId, updates as never);
|
await this.contractsRepository.update(contractId, updates as never);
|
||||||
await this.regenerateContractPdf(contractId, contract.reference);
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.signedActive(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
|
|||||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { OtpModule } from '../otp/otp.module';
|
import { OtpModule } from '../otp/otp.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller';
|
|||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
import { BookingClearanceService } from './booking-clearance.service';
|
import { BookingClearanceService } from './booking-clearance.service';
|
||||||
@@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
MinioModule,
|
MinioModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||||
@@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractsService,
|
ContractsService,
|
||||||
ContractsRepository,
|
ContractsRepository,
|
||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
|
ContractNotifierService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
ClearanceWorkflowService,
|
ClearanceWorkflowService,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service';
|
|||||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,7 @@ export class GlOperationsService {
|
|||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get bookings() {
|
private get bookings() {
|
||||||
@@ -64,7 +66,11 @@ export class GlOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getBooking(bookingId: string): Promise<Booking> {
|
private async getBooking(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookings.findOne({ where: { id: bookingId } });
|
// company is loaded so customer notifications have a phone/email to target.
|
||||||
|
const booking = await this.bookings.findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
relations: { company: true },
|
||||||
|
});
|
||||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
@@ -447,6 +453,7 @@ export class GlOperationsService {
|
|||||||
void userId;
|
void userId;
|
||||||
const summary = await this.finalInvoiceSummary(bookingId);
|
const summary = await this.finalInvoiceSummary(bookingId);
|
||||||
if (!summary) throw new NotFoundException('Final invoice could not be created.');
|
if (!summary) throw new NotFoundException('Final invoice could not be created.');
|
||||||
|
this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +462,7 @@ export class GlOperationsService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
): Promise<{ uploaded: boolean }> {
|
): Promise<{ uploaded: boolean }> {
|
||||||
await this.getBooking(bookingId);
|
const booking = await this.getBooking(bookingId);
|
||||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||||
|
|
||||||
const invoice = await this.billingService.findInvoice(
|
const invoice = await this.billingService.findInvoice(
|
||||||
@@ -482,6 +489,7 @@ export class GlOperationsService {
|
|||||||
code: 'final_invoice_slip',
|
code: 'final_invoice_slip',
|
||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
this.notifier.dutySlipUploadedToStaff(booking, 'final');
|
||||||
return { uploaded: true };
|
return { uploaded: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +498,7 @@ export class GlOperationsService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||||
await this.getBooking(bookingId);
|
const booking = await this.getBooking(bookingId);
|
||||||
const invoice = await this.billingService.findInvoice(
|
const invoice = await this.billingService.findInvoice(
|
||||||
Freight.InvoiceSource.Booking,
|
Freight.InvoiceSource.Booking,
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -507,6 +515,7 @@ export class GlOperationsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.billingService.markInvoiceAsPaid(invoice.id);
|
await this.billingService.markInvoiceAsPaid(invoice.id);
|
||||||
|
this.notifier.finalInvoicePaid(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
void userId;
|
void userId;
|
||||||
@@ -575,6 +584,7 @@ export class GlOperationsService {
|
|||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
|
||||||
return { advised: true, skipped: false };
|
return { advised: true, skipped: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +615,7 @@ export class GlOperationsService {
|
|||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
);
|
);
|
||||||
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
|
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||||||
|
this.notifier.dutySlipUploadedToStaff(booking, 'second');
|
||||||
return { milestoneCompleted: true };
|
return { milestoneCompleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ export class NotificationRecipientsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recipients.allBackoffice) {
|
||||||
|
try {
|
||||||
|
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
|
||||||
|
ids.add(uid);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => {
|
|||||||
bookingsRepository as never,
|
bookingsRepository as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
schedulingRescheduleRepository as never,
|
schedulingRescheduleRepository as never,
|
||||||
|
{
|
||||||
|
rescheduled: jest.fn(),
|
||||||
|
removedFromTrain: jest.fn(),
|
||||||
|
maintenanceMoved: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
|
|||||||
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
|
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||||
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export class SchedulingRescheduleService {
|
|||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
|
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
|
||||||
|
private readonly notifier: BookingNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Preview who is retained, displaced, and readmitted on a schedule. */
|
/** Preview who is retained, displaced, and readmitted on a schedule. */
|
||||||
@@ -193,9 +195,64 @@ export class SchedulingRescheduleService {
|
|||||||
displacedBookingIds: dto.displacedBookingIds,
|
displacedBookingIds: dto.displacedBookingIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify affected customers (SMS + email). Best-effort — a notification
|
||||||
|
// failure must never fail the reschedule, so each send is fire-and-forget
|
||||||
|
// inside the notifier. Government pre-empt already notifies via the batch
|
||||||
|
// displaced() path, so skip removed-from-train notices for that trigger.
|
||||||
|
// Use the new departure date when the reschedule moved it (the in-memory
|
||||||
|
// `schedule` still holds the pre-update date).
|
||||||
|
const effectiveDeparture = dto.newDepartureDate
|
||||||
|
? new Date(dto.newDepartureDate)
|
||||||
|
: schedule.scheduledDepartureDate;
|
||||||
|
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
|
||||||
|
|
||||||
return { plan, schedule: assignResult };
|
return { plan, schedule: assignResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan out reschedule notifications: bookings that stayed on the train hear the
|
||||||
|
* new departure date; bookings dropped off the train (staff reschedule, not a
|
||||||
|
* government pre-empt) hear they were removed. Loads each booking with its
|
||||||
|
* company so the notifier has a phone/email to reach.
|
||||||
|
*/
|
||||||
|
private async notifyRescheduleOutcome(
|
||||||
|
dto: ExecuteRescheduleDto,
|
||||||
|
newDeparture: Date | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE';
|
||||||
|
const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT';
|
||||||
|
|
||||||
|
if (newDeparture) {
|
||||||
|
for (const bookingId of dto.finalBookingIds) {
|
||||||
|
const booking = await this.loadBookingForNotify(bookingId);
|
||||||
|
if (!booking) continue;
|
||||||
|
if (isMaintenance) {
|
||||||
|
this.notifier.maintenanceMoved(booking, newDeparture);
|
||||||
|
} else {
|
||||||
|
this.notifier.rescheduled(booking, newDeparture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Government pre-empt displacements are already announced by the batch
|
||||||
|
// displaced() notice — don't double-notify. Staff reschedules are not.
|
||||||
|
if (!isGovPreempt) {
|
||||||
|
for (const bookingId of dto.displacedBookingIds) {
|
||||||
|
const booking = await this.loadBookingForNotify(bookingId);
|
||||||
|
if (!booking) continue;
|
||||||
|
this.notifier.removedFromTrain(booking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBookingForNotify(bookingId: string): Promise<Booking | null> {
|
||||||
|
try {
|
||||||
|
return await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Maintenance shortcut: new departure + rebalance. */
|
/** Maintenance shortcut: new departure + rebalance. */
|
||||||
async maintenanceReschedule(
|
async maintenanceReschedule(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
|
|||||||
@@ -1517,6 +1517,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
"PREPAID",
|
"PREPAID",
|
||||||
);
|
);
|
||||||
await this.notifier.payNow(booking, deadline);
|
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). */
|
/** 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);
|
this.notifier.secured(booking, reason);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
void this.markWagonAllocatedMilestone(booking.id);
|
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> {
|
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
|
* 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
|
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationType,
|
||||||
|
NotifyInput,
|
||||||
|
} from '@edr/types';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingNotifierService {
|
export class BookingNotifierService {
|
||||||
private readonly logger = new Logger(BookingNotifierService.name);
|
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 {
|
private ref(b: Booking): string {
|
||||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
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> {
|
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' });
|
||||||
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
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');
|
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 ` +
|
`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.`;
|
`(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)');
|
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 {
|
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||||
@@ -73,11 +109,13 @@ export class BookingNotifierService {
|
|||||||
reason === 'gov' ? ' (government)' : ''
|
reason === 'gov' ? ' (government)' : ''
|
||||||
}.`;
|
}.`;
|
||||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||||
|
this.inApp(b, 'Wagon allocated', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
expired(b: Booking): void {
|
expired(b: Booking): void {
|
||||||
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
|
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
|
||||||
void this.notifyContact(b, msg, 'EXPIRED');
|
void this.notifyContact(b, msg, 'EXPIRED');
|
||||||
|
this.inApp(b, 'Payment window expired', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleFull(b: Booking): void {
|
scheduleFull(b: Booking): void {
|
||||||
@@ -100,5 +138,42 @@ export class BookingNotifierService {
|
|||||||
displaced(b: Booking): void {
|
displaced(b: Booking): void {
|
||||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
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');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
socket.data.userId = userId;
|
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. */
|
/** 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 { Cron } from '@nestjs/schedule';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from '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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||||
@@ -41,6 +46,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
private readonly gateway: BookingWindowGateway,
|
private readonly gateway: BookingWindowGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -380,22 +386,26 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
|
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const rows: Array<{ phone: string | null; email: string | null }> =
|
const rows: Array<{
|
||||||
await this.dataSource.query(
|
company_id: string;
|
||||||
`SELECT DISTINCT
|
phone: string | null;
|
||||||
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
email: string | null;
|
||||||
COALESCE(co.email, co.general_manager_email) AS email
|
}> = await this.dataSource.query(
|
||||||
FROM freight.contract_routes cr
|
`SELECT DISTINCT
|
||||||
JOIN freight.contracts c
|
c.company_id,
|
||||||
ON c.id = cr.contract_id
|
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
||||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
COALESCE(co.email, co.general_manager_email) AS email
|
||||||
AND c.deleted_at IS NULL
|
FROM freight.contract_routes cr
|
||||||
JOIN freight.companies co ON co.id = c.company_id
|
JOIN freight.contracts c
|
||||||
WHERE cr.origin_yard_id = $1
|
ON c.id = cr.contract_id
|
||||||
AND cr.destination_yard_id = $2
|
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||||
AND cr.deleted_at IS NULL`,
|
AND c.deleted_at IS NULL
|
||||||
[schedule.originStationId, schedule.destinationStationId],
|
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;
|
if (!rows.length) return;
|
||||||
|
|
||||||
const closes = schedule.windowClosesAt
|
const closes = schedule.windowClosesAt
|
||||||
@@ -410,6 +420,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
|
|
||||||
const seenPhone = new Set<string>();
|
const seenPhone = new Set<string>();
|
||||||
const seenEmail = new Set<string>();
|
const seenEmail = new Set<string>();
|
||||||
|
const seenCompany = new Set<string>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.phone && !seenPhone.has(r.phone)) {
|
if (r.phone && !seenPhone.has(r.phone)) {
|
||||||
seenPhone.add(r.phone);
|
seenPhone.add(r.phone);
|
||||||
@@ -423,9 +434,23 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
.directSend('email', r.email, msg)
|
.directSend('email', r.email, msg)
|
||||||
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
|
.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(
|
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) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
|||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { ContractsModule } from '../contracts/contracts.module';
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -56,6 +57,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
@@ -76,6 +78,11 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
BookingSplitService,
|
BookingSplitService,
|
||||||
IntercityService,
|
IntercityService,
|
||||||
],
|
],
|
||||||
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
|
exports: [
|
||||||
|
TrainSchedulingService,
|
||||||
|
BookingBatchService,
|
||||||
|
BookingWindowService,
|
||||||
|
BookingNotifierService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TrainSchedulingModule {}
|
export class TrainSchedulingModule {}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
@@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.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 { Container } from '../container-management/entities/container.entity';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||||
@@ -274,9 +276,45 @@ export class TrainSchedulingService {
|
|||||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
private readonly configService?: ConfigService,
|
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
|
* 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 —
|
* portal home card and backoffice GL/batch views update in real time —
|
||||||
@@ -1121,26 +1159,32 @@ export class TrainSchedulingService {
|
|||||||
{ country: schedule.destinationCountry },
|
{ country: schedule.destinationCountry },
|
||||||
);
|
);
|
||||||
if (direction === 'IMPORT') {
|
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 {
|
return {
|
||||||
direction,
|
direction,
|
||||||
action: 'IMPORT_AUTO_UNLOAD',
|
action: 'IMPORT_AUTO_UNLOAD',
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
result,
|
||||||
scheduleId,
|
|
||||||
'SYSTEM_TRAIN_ARRIVAL',
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
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 {
|
return {
|
||||||
direction,
|
direction,
|
||||||
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
result,
|
||||||
scheduleId,
|
|
||||||
'SYSTEM_TRAIN_ARRIVAL',
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1443,6 +1487,20 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
// Dispatch closed the window — drop it from portal/GL cards right away.
|
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||||||
void this.emitWindowState(scheduleId);
|
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);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1599,6 +1657,13 @@ export class TrainSchedulingService {
|
|||||||
LoadingStatus.Loaded,
|
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);
|
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 detail = await this.getTrainScheduleById(scheduleId);
|
||||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||||
return Object.assign(detail, { warehouseAutomation });
|
return Object.assign(detail, { warehouseAutomation });
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ export function useBookingWindowSocket(enabled: boolean = true) {
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deliberate console breadcrumbs: "live updates not arriving" is only
|
||||||
|
// diagnosable from the browser when connect/reject outcomes are visible.
|
||||||
|
socket.on("connect", () =>
|
||||||
|
console.debug("[booking-windows] socket connected", socket.id),
|
||||||
|
);
|
||||||
|
socket.on("connect_error", (err) =>
|
||||||
|
console.warn("[booking-windows] socket connect failed:", err.message),
|
||||||
|
);
|
||||||
|
socket.on("disconnect", (reason) =>
|
||||||
|
console.debug("[booking-windows] socket disconnected:", reason),
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(
|
socket.on(
|
||||||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||||
(_event: BookingWindowPhaseEvent) => {
|
(_event: BookingWindowPhaseEvent) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NotificationType } from "@edr/types";
|
import { NotificationType } from "@edr/types";
|
||||||
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
|
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
|
||||||
import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react";
|
import { Bell, ClipboardCheck, FileSignature, Inbox, Wallet } from "lucide-react";
|
||||||
|
|
||||||
const ICON_SIZE = 17;
|
const ICON_SIZE = 17;
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ export function resolveNotificationVisual(
|
|||||||
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
|
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
|
||||||
case NotificationType.CLEARANCE_REVIEW:
|
case NotificationType.CLEARANCE_REVIEW:
|
||||||
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
|
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
|
||||||
|
case NotificationType.CONTRACT_STATUS:
|
||||||
|
return { icon: <FileSignature size={ICON_SIZE} />, color: "indigo" };
|
||||||
default:
|
default:
|
||||||
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
|
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
|
||||||
}
|
}
|
||||||
@@ -39,14 +41,32 @@ export function resolveNotificationHref(
|
|||||||
if (item.link) return item.link;
|
if (item.link) return item.link;
|
||||||
const data = item.data ?? {};
|
const data = item.data ?? {};
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case NotificationType.REQUEST_SUBMITTED:
|
case NotificationType.REQUEST_SUBMITTED: {
|
||||||
|
const bookingId = asId(data.bookingId);
|
||||||
|
if (bookingId) return `/dashboard/booking-requests/${bookingId}`;
|
||||||
|
const contractId = asId(data.contractId);
|
||||||
|
if (contractId) return `/dashboard/contract-requests/${contractId}`;
|
||||||
return "/dashboard/booking-requests";
|
return "/dashboard/booking-requests";
|
||||||
|
}
|
||||||
case NotificationType.PAYMENT_RECEIVED: {
|
case NotificationType.PAYMENT_RECEIVED: {
|
||||||
|
const bookingId = asId(data.bookingId);
|
||||||
|
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
|
||||||
const id = asId(data.customerId);
|
const id = asId(data.customerId);
|
||||||
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
|
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
|
||||||
}
|
}
|
||||||
case NotificationType.CLEARANCE_REVIEW:
|
case NotificationType.CLEARANCE_REVIEW: {
|
||||||
|
const bookingId = asId(data.bookingId);
|
||||||
|
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
|
||||||
|
const contractId = asId(data.contractId);
|
||||||
|
if (contractId) return `/dashboard/contracts/clearance/${contractId}`;
|
||||||
return "/dashboard/arrival-queue";
|
return "/dashboard/arrival-queue";
|
||||||
|
}
|
||||||
|
case NotificationType.CONTRACT_STATUS: {
|
||||||
|
const contractId = asId(data.contractId);
|
||||||
|
return contractId
|
||||||
|
? `/dashboard/contract-requests/${contractId}`
|
||||||
|
: "/dashboard/contract-requests";
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ export function useBookingWindowSocket(enabled: boolean) {
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deliberate console breadcrumbs: "live updates not arriving" is only
|
||||||
|
// diagnosable from the browser when connect/reject outcomes are visible.
|
||||||
|
socket.on("connect", () =>
|
||||||
|
console.debug("[booking-windows] socket connected", socket.id),
|
||||||
|
);
|
||||||
|
socket.on("connect_error", (err) =>
|
||||||
|
console.warn("[booking-windows] socket connect failed:", err.message),
|
||||||
|
);
|
||||||
|
socket.on("disconnect", (reason) =>
|
||||||
|
console.debug("[booking-windows] socket disconnected:", reason),
|
||||||
|
);
|
||||||
|
|
||||||
socket.on(
|
socket.on(
|
||||||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||||
(_event: BookingWindowPhaseEvent) => {
|
(_event: BookingWindowPhaseEvent) => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Bell,
|
Bell,
|
||||||
|
CalendarClock,
|
||||||
|
FileSignature,
|
||||||
FileWarning,
|
FileWarning,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
@@ -25,6 +27,10 @@ export function resolveNotificationVisual(
|
|||||||
return { icon: <FileWarning size={ICON_SIZE} />, color: "orange" };
|
return { icon: <FileWarning size={ICON_SIZE} />, color: "orange" };
|
||||||
case NotificationType.BOOKING_STATUS:
|
case NotificationType.BOOKING_STATUS:
|
||||||
return { icon: <Package size={ICON_SIZE} />, color: "blue" };
|
return { icon: <Package size={ICON_SIZE} />, color: "blue" };
|
||||||
|
case NotificationType.CONTRACT_STATUS:
|
||||||
|
return { icon: <FileSignature size={ICON_SIZE} />, color: "indigo" };
|
||||||
|
case NotificationType.SCHEDULE_UPDATE:
|
||||||
|
return { icon: <CalendarClock size={ICON_SIZE} />, color: "cyan" };
|
||||||
case NotificationType.INVOICE_ISSUED:
|
case NotificationType.INVOICE_ISSUED:
|
||||||
return { icon: <Receipt size={ICON_SIZE} />, color: "violet" };
|
return { icon: <Receipt size={ICON_SIZE} />, color: "violet" };
|
||||||
default:
|
default:
|
||||||
@@ -55,8 +61,18 @@ export function resolveNotificationHref(
|
|||||||
const id = asId(data.bookingId);
|
const id = asId(data.bookingId);
|
||||||
return id ? `/bookings/${id}` : null;
|
return id ? `/bookings/${id}` : null;
|
||||||
}
|
}
|
||||||
|
case NotificationType.CONTRACT_STATUS: {
|
||||||
|
const id = asId(data.contractId);
|
||||||
|
return id ? `/contracts/${id}` : "/contracts";
|
||||||
|
}
|
||||||
|
case NotificationType.SCHEDULE_UPDATE: {
|
||||||
|
const id = asId(data.bookingId);
|
||||||
|
return id ? `/bookings/${id}` : "/bookings/new";
|
||||||
|
}
|
||||||
case NotificationType.CLEARANCE_DECISION:
|
case NotificationType.CLEARANCE_DECISION:
|
||||||
case NotificationType.DOCUMENT_ACTION: {
|
case NotificationType.DOCUMENT_ACTION: {
|
||||||
|
const bookingId = asId(data.bookingId);
|
||||||
|
if (bookingId) return `/bookings/${bookingId}`;
|
||||||
const id = asId(data.contractId);
|
const id = asId(data.contractId);
|
||||||
return id ? `/contracts/${id}` : "/contracts";
|
return id ? `/contracts/${id}` : "/contracts";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Check, MoveRight } from "lucide-react";
|
|||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
|
import { ARRIVAL_STAGE, PROGRESS_STAGES, STATUS_MAP, resolveStage } from "../constants";
|
||||||
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
||||||
import { SectionCard } from "./layout";
|
import { SectionCard } from "./layout";
|
||||||
|
|
||||||
@@ -65,7 +65,18 @@ export function StatusHero({
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const status = booking.status;
|
const status = booking.status;
|
||||||
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
|
const stage = resolveStage(booking);
|
||||||
|
// The Arrival stage has no booking status of its own — it lights up from the
|
||||||
|
// train's ARRIVED state, so the headline is overridden here.
|
||||||
|
const cfg =
|
||||||
|
stage === ARRIVAL_STAGE
|
||||||
|
? {
|
||||||
|
title: "Train arrived at destination",
|
||||||
|
description:
|
||||||
|
"Your shipment reached its destination yard and is being unloaded and prepared for release.",
|
||||||
|
stage,
|
||||||
|
}
|
||||||
|
: (STATUS_MAP[status] ?? STATUS_MAP.DRAFT);
|
||||||
const negative = isNegative(status);
|
const negative = isNegative(status);
|
||||||
const draft = isDraftLike(status);
|
const draft = isDraftLike(status);
|
||||||
|
|
||||||
@@ -107,7 +118,7 @@ export function StatusHero({
|
|||||||
|
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<ProgressTracker
|
<ProgressTracker
|
||||||
current={cfg.stage}
|
current={stage}
|
||||||
tone={draft ? "ink" : "green"}
|
tone={draft ? "ink" : "green"}
|
||||||
negative={negative}
|
negative={negative}
|
||||||
/>
|
/>
|
||||||
@@ -139,7 +150,7 @@ function ProgressTracker({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-start" style={{ minWidth: 440 }}>
|
<div className="flex items-start" style={{ minWidth: 520 }}>
|
||||||
{PROGRESS_STAGES.map((stage, idx) => {
|
{PROGRESS_STAGES.map((stage, idx) => {
|
||||||
const state =
|
const state =
|
||||||
idx < current ? "done" : idx === current ? "active" : "idle";
|
idx < current ? "done" : idx === current ? "active" : "idle";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
FileText,
|
FileText,
|
||||||
|
MapPin,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Ship,
|
Ship,
|
||||||
@@ -52,6 +53,14 @@ export const PROGRESS_STAGES = [
|
|||||||
icon: Train,
|
icon: Train,
|
||||||
statuses: ["EXPIRED", "IN_TRANSIT"],
|
statuses: ["EXPIRED", "IN_TRANSIT"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// No booking status maps here: the booking stays IN_TRANSIT until
|
||||||
|
// delivery, so this stage lights up from the assigned train's own status
|
||||||
|
// (trainScheduleStatus === "ARRIVED") — see resolveStage.
|
||||||
|
label: "Arrival",
|
||||||
|
icon: MapPin,
|
||||||
|
statuses: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Complete",
|
label: "Complete",
|
||||||
icon: PackageCheck,
|
icon: PackageCheck,
|
||||||
@@ -59,6 +68,30 @@ export const PROGRESS_STAGES = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Stage index of the Arrival step (train ARRIVED, cargo not yet delivered). */
|
||||||
|
export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
|
||||||
|
(s) => s.label === "Arrival",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage for a booking, factoring in the assigned train's operational status:
|
||||||
|
* a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its
|
||||||
|
* train has ARRIVED the tracker advances to the Arrival stage.
|
||||||
|
*/
|
||||||
|
export function resolveStage(booking: {
|
||||||
|
status: string;
|
||||||
|
trainScheduleStatus?: string | null;
|
||||||
|
}): number {
|
||||||
|
const base = STATUS_MAP[booking.status]?.stage ?? 0;
|
||||||
|
if (
|
||||||
|
booking.status === "IN_TRANSIT" &&
|
||||||
|
booking.trainScheduleStatus === "ARRIVED"
|
||||||
|
) {
|
||||||
|
return ARRIVAL_STAGE;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
export const STATUS_MAP: Record<
|
export const STATUS_MAP: Record<
|
||||||
string,
|
string,
|
||||||
{ title: string; description: string; stage: number }
|
{ title: string; description: string; stage: number }
|
||||||
@@ -209,7 +242,7 @@ export const STATUS_MAP: Record<
|
|||||||
title: "Contract closed",
|
title: "Contract closed",
|
||||||
description:
|
description:
|
||||||
"This general contract is closed — its reserved quantity has been used or its window has elapsed.",
|
"This general contract is closed — its reserved quantity has been used or its window has elapsed.",
|
||||||
stage: 7,
|
stage: 8,
|
||||||
},
|
},
|
||||||
PRICE_CHANGED_PENDING_CONFIRM: {
|
PRICE_CHANGED_PENDING_CONFIRM: {
|
||||||
title: "Price changed — confirm to proceed",
|
title: "Price changed — confirm to proceed",
|
||||||
@@ -235,12 +268,12 @@ export const STATUS_MAP: Record<
|
|||||||
COMPLETED: {
|
COMPLETED: {
|
||||||
title: "Service complete",
|
title: "Service complete",
|
||||||
description: "Cargo delivered and service successfully terminated.",
|
description: "Cargo delivered and service successfully terminated.",
|
||||||
stage: 7,
|
stage: 8,
|
||||||
},
|
},
|
||||||
DELIVERED: {
|
DELIVERED: {
|
||||||
title: "Service complete",
|
title: "Service complete",
|
||||||
description: "Cargo delivered and service successfully terminated.",
|
description: "Cargo delivered and service successfully terminated.",
|
||||||
stage: 7,
|
stage: 8,
|
||||||
},
|
},
|
||||||
REJECTED: {
|
REJECTED: {
|
||||||
title: "Booking rejected",
|
title: "Booking rejected",
|
||||||
|
|||||||
@@ -438,6 +438,13 @@ export interface IBooking extends BaseEntity {
|
|||||||
contractReference?: string | null;
|
contractReference?: string | null;
|
||||||
trainId?: string | null;
|
trainId?: string | null;
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
|
/**
|
||||||
|
* Operational status of the assigned train (DRAFT/SCHEDULED/DISPATCHED/
|
||||||
|
* ARRIVED), joined on booking detail. The booking status stays IN_TRANSIT
|
||||||
|
* from dispatch until delivery, so the portal stepper reads arrival from
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
|
trainScheduleStatus?: TrainScheduleStatus | string | null;
|
||||||
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
|
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
|
||||||
bookingType?: BookingType;
|
bookingType?: BookingType;
|
||||||
/** General contracts only: when ordering closes (null until active / for one-time). */
|
/** General contracts only: when ordering closes (null until active / for one-time). */
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export enum NotificationType {
|
|||||||
CLEARANCE_DECISION = "CLEARANCE_DECISION",
|
CLEARANCE_DECISION = "CLEARANCE_DECISION",
|
||||||
DOCUMENT_ACTION = "DOCUMENT_ACTION",
|
DOCUMENT_ACTION = "DOCUMENT_ACTION",
|
||||||
BOOKING_STATUS = "BOOKING_STATUS",
|
BOOKING_STATUS = "BOOKING_STATUS",
|
||||||
|
CONTRACT_STATUS = "CONTRACT_STATUS",
|
||||||
|
SCHEDULE_UPDATE = "SCHEDULE_UPDATE",
|
||||||
INVOICE_ISSUED = "INVOICE_ISSUED",
|
INVOICE_ISSUED = "INVOICE_ISSUED",
|
||||||
// Backoffice-facing (staff)
|
// Backoffice-facing (staff)
|
||||||
REQUEST_SUBMITTED = "REQUEST_SUBMITTED",
|
REQUEST_SUBMITTED = "REQUEST_SUBMITTED",
|
||||||
@@ -87,6 +89,8 @@ export interface NotificationRecipients {
|
|||||||
companyProfileId?: string;
|
companyProfileId?: string;
|
||||||
/** Backoffice: all current employees of this organization. */
|
/** Backoffice: all current employees of this organization. */
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
|
/** Backoffice: every current employee across all organizations. */
|
||||||
|
allBackoffice?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Input any subsystem passes to `NotificationInboxService.notify(...)`. */
|
/** Input any subsystem passes to `NotificationInboxService.notify(...)`. */
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -193,6 +193,9 @@ importers:
|
|||||||
jest:
|
jest:
|
||||||
specifier: ^29.7.0
|
specifier: ^29.7.0
|
||||||
version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.3
|
||||||
|
version: 4.8.3
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
|
|||||||
Reference in New Issue
Block a user