Merge remote-tracking branch 'origin/dev' into Truckdetantion

This commit is contained in:
Hagernesh
2026-07-07 08:49:20 +00:00
252 changed files with 16873 additions and 1684 deletions

View File

@@ -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 { 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 { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
@@ -40,6 +44,21 @@ export class BackofficeService {
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(
organizationId: string,
dto: CreateOrganizationUserDto,

View File

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

View File

@@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ 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 };
}

View File

@@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ 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 };
}
@@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ 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 };
}
@@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never,
{} as never,
{} as never,
{} as never, // invoiceService
filesService as never,
fileUploadSettingsService as never,
{} as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
{} as never, // invoiceService
{ 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 };
}

View File

@@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service';
/**
* Operation-request review for general-contract drawdown orders:
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
* - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/
* 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.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
const booking = {
id: 'b-1',
reference: 'BKG-1',
status: 'OPERATION_REQUEST_PENDING',
originYardId: 'o-1',
destinationYardId: 'd-1',
@@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => {
};
const bookingBatchService = {
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(
@@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{} as never, // workflowService
invoiceService 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 () => {
const { service, bookingsRepository, bookingBatchService } =
it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => {
const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
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 () => {
const { service, bookingsRepository, bookingBatchService } =
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => {
const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('ROAD_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
);
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
});

View File

@@ -4,6 +4,7 @@ import {
Inject,
Injectable,
Logger,
Optional,
} from "@nestjs/common";
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 { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
@@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
@@ -53,7 +56,8 @@ export class BookingTransitionService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
@@ -124,6 +128,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
if (finalBooking.status === "SUBMITTED") {
this.notifier.submittedToStaff(finalBooking);
}
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -204,6 +211,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id,
);
if (finalBooking.status === "SUBMITTED") {
this.notifier.submittedToStaff(finalBooking);
}
return {
bookingId: finalBooking.id,
status: finalBooking.status,
@@ -233,7 +243,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "CHANGES_REQUESTED",
} 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. */
@@ -284,7 +296,9 @@ export class BookingTransitionService {
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.bookingsService.findById(updated!.id);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.accepted(fresh);
return fresh;
}
async staffReject(
@@ -305,7 +319,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} 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(
@@ -394,7 +410,9 @@ export class BookingTransitionService {
if (allDone) {
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);
@@ -435,7 +453,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} 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> {
@@ -446,7 +466,9 @@ export class BookingTransitionService {
status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(),
} 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> {
@@ -456,7 +478,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "IN_TRANSIT",
} 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> {
@@ -467,7 +491,28 @@ export class BookingTransitionService {
status: "COMPLETED",
endDate: new Date(),
} 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> {
@@ -491,7 +536,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: "CANCELLED",
} 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);
}
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);
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedGeneralCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
@@ -912,7 +964,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} 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",
scheduledDate: date,
} 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, {
status: "OPERATION_CHANGES_REQUESTED",
} 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.
@@ -1037,7 +1095,9 @@ export class BookingTransitionService {
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} 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, {
@@ -1072,7 +1132,9 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// 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<

View File

@@ -15,11 +15,13 @@ import {
UnauthorizedException,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { BookingStaff, BookingView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import {
@@ -195,6 +197,7 @@ export class BookingsController {
}
@Get("by-company/:companyId/customer-view")
@BookingView()
@ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
@@ -205,6 +208,7 @@ export class BookingsController {
}
@Get("list-summary")
@BookingView()
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
@@ -226,6 +230,7 @@ export class BookingsController {
}
@Get("queues/:queue")
@BookingView()
@ApiOperation({
summary: "List bookings for a dashboard queue",
description: "Queues: intake, approval, signatures, marketing, finance",
@@ -956,12 +961,18 @@ export class BookingsController {
}
@Post(":id/contract/sign")
@UseGuards(JwtGuard)
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: TCurrentUser,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
// Staff signature needs the sign permission; customer signs their own booking.
if (dto.role !== "CUSTOMER") {
assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff);
}
const userId = req.user?.id ?? req.user?.sub;
const booking = await this.contractService.signContract(id, dto, {
signerUserId: userId,

View File

@@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.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 { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
@@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckContainer,
]),
BillingModule,
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
@@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
BookingLifecycleNotifierService,
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
@@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
BookingLifecycleNotifierService,
CustomerTruckService,
ContainerReceiptService,
],

View File

@@ -176,6 +176,44 @@ export class BookingsService {
}
/** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
* one non-retired route passes the origin strictly before the destination in
* its milestone order — that is the corridor an import/export train can
* serve the booking on.
*/
private async assertIntercityCorridorExists(
originYardId: string,
destinationYardId: string,
): Promise<void> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
if (yards.some((y) => y.country !== 'Ethiopia')) {
throw new BadRequestException(
'Intercity bookings only run between Ethiopian yards',
);
}
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT r.id
FROM freight.routes r
JOIN freight.route_milestones mo
ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
JOIN freight.route_milestones md
ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
WHERE mo.sequence_no < md.sequence_no
AND r.status = 'AVAILABLE'
AND r.deleted_at IS NULL
LIMIT 1`,
[originYardId, destinationYardId],
);
if (rows.length === 0) {
throw new BadRequestException(
'No route passes through this origin and destination in order — intercity service is not available on this corridor',
);
}
}
private async resolveTradeDirectionForBooking(
originYardId: string,
destinationYardId: string,
@@ -607,6 +645,23 @@ export class BookingsService {
dto.tradeDirection,
);
// Intercity (DOMESTIC) bookings never get their own train — they ride on a
// passing import/export train, so there is no booking window and no date to
// pin. All we require at creation is that the corridor actually lies on a
// route (origin before destination in some route's milestone order); staff
// accept the booking onto a concrete train at finalize time.
if (tradeDirection === 'DOMESTIC') {
if (dto.scheduledDate || dto.trainScheduleId) {
throw new BadRequestException(
'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later',
);
}
await this.assertIntercityCorridorExists(
dto.originYardId,
dto.destinationYardId,
);
}
// Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
@@ -1333,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;
}

View File

@@ -33,6 +33,11 @@ import { ETradeService } from "./services/etrade.service";
CompanyDashboardRepository,
ETradeService,
],
exports: [CompaniesService],
exports: [
CompaniesService,
// Consumed by NotificationInboxModule for portal recipient targeting.
ExternalProfileRepository,
CompanyProfileRepository,
],
})
export class CompaniesModule { }

View File

@@ -334,9 +334,28 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
for (const profile of company.companyProfiles) {
profile.businessLicenseFiles = await this.signLicenseFiles(
profile.businessLicenseFiles,
);
}
return company;
}
/**
* Business-license files are stored as raw, unsigned MinIO URLs (see
* `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
* directly. Sign each one with a short-lived URL before it reaches a response.
*/
private async signLicenseFiles(
files?: BusinessLicenseFile[] | null,
): Promise<BusinessLicenseFile[]> {
if (!files?.length) return [];
return Promise.all(
files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
);
}
/**
* Validate an explicitly-chosen company profile for a booking: it must belong
* to the booking's company and be Active. Used for government bookings (staff

View File

@@ -0,0 +1,53 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ComplianceService } from './compliance.service';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance')
@Controller('compliance')
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Post()
@ApiOperation({ summary: 'Create a compliance record' })
create(@Body() dto: CreateComplianceRecordDto) {
return this.complianceService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List compliance records' })
findAll(
@Query('vehicleId') vehicleId?: string,
@Query('type') type?: ComplianceType,
) {
return this.complianceService.findAll({ vehicleId, type });
}
@Get('alerts')
@ApiOperation({ summary: 'List overdue / due-soon compliance & expiry alerts' })
getAlerts() {
return this.complianceService.getAlerts();
}
@Get(':id')
@ApiOperation({ summary: 'Get a compliance record by ID' })
findOne(@Param('id') id: string) {
return this.complianceService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a compliance record' })
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
return this.complianceService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Soft-delete a compliance record' })
remove(@Param('id') id: string) {
return this.complianceService.remove(id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ComplianceRecord } from './entities/compliance-record.entity';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
import { ComplianceService } from './compliance.service';
import { ComplianceRepository } from './compliance.repository';
import { ComplianceController } from './compliance.controller';
@Module({
imports: [TypeOrmModule.forFeature([ComplianceRecord, Vehicle, Driver])],
providers: [ComplianceService, ComplianceRepository],
controllers: [ComplianceController],
exports: [ComplianceService],
})
export class ComplianceModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { ComplianceRecord, ComplianceType } from './entities/compliance-record.entity';
@Injectable()
export class ComplianceRepository extends BaseRepository<ComplianceRecord> {
constructor(
@InjectRepository(ComplianceRecord)
private readonly complianceRepository: Repository<ComplianceRecord>,
) {
super(complianceRepository);
}
async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
const where: FindOptionsWhere<ComplianceRecord> = {};
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
if (filter.type) where.type = filter.type;
return this.complianceRepository.find({
where,
order: { expiryDate: 'ASC' },
});
}
}

View File

@@ -0,0 +1,184 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { ComplianceRepository } from './compliance.repository';
import {
ComplianceRecord,
ComplianceStatus,
ComplianceType,
} from './entities/compliance-record.entity';
import {
CreateComplianceRecordDto,
UpdateComplianceRecordDto,
} from './dto/create-compliance-record.dto';
import { Vehicle } from '../vehicles/entities/vehicle.entity';
import { Driver } from '../drivers/entities/driver.entity';
const DUE_SOON_DAYS = 30;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export type AlertSeverity = 'OVERDUE' | 'DUE_SOON';
export interface ComplianceAlert {
vehicleId: string;
vehiclePlate?: string;
kind: string;
label: string;
expiryDate: string;
daysUntil: number;
severity: AlertSeverity;
}
@Injectable()
export class ComplianceService {
constructor(
private readonly complianceRepository: ComplianceRepository,
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
) {}
async create(dto: CreateComplianceRecordDto): Promise<ComplianceRecord> {
return this.complianceRepository.create({
...dto,
status: dto.status ?? this.deriveStatus(dto.expiryDate),
});
}
async findAll(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
return this.complianceRepository.findWithFilters(filter);
}
async findById(id: string): Promise<ComplianceRecord> {
const record = await this.complianceRepository.findById(id);
if (!record) {
throw new NotFoundException(`Compliance record ${id} not found`);
}
return record;
}
async update(id: string, dto: UpdateComplianceRecordDto): Promise<ComplianceRecord> {
await this.findById(id);
const nextExpiry = dto.expiryDate;
const updated = await this.complianceRepository.update(id, {
...dto,
// Re-derive status when expiry changes and the caller didn't set it explicitly.
status: dto.status ?? (nextExpiry ? this.deriveStatus(nextExpiry) : undefined),
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.complianceRepository.softDelete(id);
}
/**
* Flat list of compliance items that are overdue or due within 30 days.
* Combines the compliance_records table with the vehicle expiry columns
* (insurance / registration / next inspection) and assigned-driver license
* expiry. `new Date()` is fine here — this is the NestJS API runtime.
*/
async getAlerts(): Promise<ComplianceAlert[]> {
const now = new Date();
const alerts: ComplianceAlert[] = [];
const vehicles = await this.vehicleRepo.find({ where: { deletedAt: IsNull() } });
const vehicleById = new Map(vehicles.map((v) => [v.id, v]));
const plateOf = (v?: Vehicle) => v?.plateNumber ?? v?.code ?? undefined;
// 1. Compliance records
const records = await this.complianceRepository.findWithFilters();
for (const record of records) {
const computed = this.computeSeverity(record.expiryDate, now);
if (!computed) continue;
const vehicle = vehicleById.get(record.vehicleId);
alerts.push({
vehicleId: record.vehicleId,
vehiclePlate: plateOf(vehicle),
kind: record.type,
label: record.documentNumber
? `${record.type} · ${record.documentNumber}`
: record.type,
expiryDate: record.expiryDate,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
// 2. Vehicle-level expiry columns
const vehicleFields: { field: keyof Vehicle; kind: string; label: string }[] = [
{ field: 'insuranceExpiry', kind: 'INSURANCE', label: 'Insurance' },
{ field: 'registrationExpiry', kind: 'REGISTRATION', label: 'Registration' },
{ field: 'nextInspectionDate', kind: 'INSPECTION', label: 'Inspection' },
];
for (const vehicle of vehicles) {
for (const { field, kind, label } of vehicleFields) {
const value = vehicle[field] as string | undefined;
if (!value) continue;
const computed = this.computeSeverity(value, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind,
label,
expiryDate: value,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
// 3. Assigned-driver license expiry
const driverIds = [
...new Set(vehicles.map((v) => v.assignedDriverId).filter((id): id is string => !!id)),
];
if (driverIds.length > 0) {
const drivers = await this.driverRepo.find({ where: { id: In(driverIds) } });
const driverById = new Map(drivers.map((d) => [d.id, d]));
for (const vehicle of vehicles) {
if (!vehicle.assignedDriverId) continue;
const driver = driverById.get(vehicle.assignedDriverId);
if (!driver?.licenseExpiryDate) continue;
const expiry =
driver.licenseExpiryDate instanceof Date
? driver.licenseExpiryDate.toISOString().slice(0, 10)
: String(driver.licenseExpiryDate);
const computed = this.computeSeverity(expiry, now);
if (!computed) continue;
alerts.push({
vehicleId: vehicle.id,
vehiclePlate: plateOf(vehicle),
kind: 'DRIVER_LICENSE',
label: `Driver License · ${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
expiryDate: expiry,
daysUntil: computed.daysUntil,
severity: computed.severity,
});
}
}
return alerts.sort((a, b) => a.daysUntil - b.daysUntil);
}
private computeSeverity(
expiryDate: string,
now: Date,
): { daysUntil: number; severity: AlertSeverity } | null {
const daysUntil = Math.ceil((new Date(expiryDate).getTime() - now.getTime()) / MS_PER_DAY);
if (daysUntil < 0) return { daysUntil, severity: 'OVERDUE' };
if (daysUntil <= DUE_SOON_DAYS) return { daysUntil, severity: 'DUE_SOON' };
return null;
}
private deriveStatus(expiryDate: string): ComplianceStatus {
const daysUntil = Math.ceil(
(new Date(expiryDate).getTime() - Date.now()) / MS_PER_DAY,
);
if (daysUntil < 0) return ComplianceStatus.EXPIRED;
if (daysUntil <= DUE_SOON_DAYS) return ComplianceStatus.EXPIRING;
return ComplianceStatus.VALID;
}
}

View File

@@ -0,0 +1,55 @@
import { IsUUID, IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
import { ComplianceType, ComplianceStatus } from '../entities/compliance-record.entity';
export class CreateComplianceRecordDto {
@IsUUID()
vehicleId!: string;
@IsEnum(ComplianceType)
type!: ComplianceType;
@IsOptional()
@IsString()
documentNumber?: string;
@IsOptional()
@IsDateString()
issuedDate?: string;
@IsDateString()
expiryDate!: string;
@IsOptional()
@IsEnum(ComplianceStatus)
status?: ComplianceStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateComplianceRecordDto {
@IsOptional()
@IsEnum(ComplianceType)
type?: ComplianceType;
@IsOptional()
@IsString()
documentNumber?: string;
@IsOptional()
@IsDateString()
issuedDate?: string;
@IsOptional()
@IsDateString()
expiryDate?: string;
@IsOptional()
@IsEnum(ComplianceStatus)
status?: ComplianceStatus;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum ComplianceType {
INSPECTION = 'INSPECTION',
INSURANCE = 'INSURANCE',
ROADWORTHINESS = 'ROADWORTHINESS',
PERMIT = 'PERMIT',
TAX = 'TAX',
}
export enum ComplianceStatus {
VALID = 'VALID',
EXPIRING = 'EXPIRING',
EXPIRED = 'EXPIRED',
}
@Entity({ name: 'compliance_records', schema: 'freight' })
@Index(['vehicleId', 'expiryDate'])
export class ComplianceRecord extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'type', type: 'varchar' })
type!: ComplianceType;
@Column({ name: 'document_number', type: 'varchar', nullable: true })
documentNumber?: string;
@Column({ name: 'issued_date', type: 'date', nullable: true })
issuedDate?: string;
@Column({ name: 'expiry_date', type: 'date' })
expiryDate!: string;
@Column({ name: 'status', type: 'varchar', default: ComplianceStatus.VALID })
status!: ComplianceStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -85,6 +85,13 @@ function makeService(overrides?: {
milestoneService as never,
dropdownSettingsService as never,
glOperationsService as never,
{
dutyAdvised: jest.fn(),
clearanceReady: jest.fn(),
documentQueried: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
} as never, // notifier
);
return {
@@ -115,12 +122,18 @@ describe('BookingClearanceService', () => {
it('records duty advice when duty applies', async () => {
const { service, milestoneService } = makeService();
await service.adviseDuty('b-general', {
dutyRequired: true,
amount: 1500,
currency: 'ETB',
declarationSerial: 'DS-1',
});
await service.adviseDuty(
'b-general',
{
dutyRequired: true,
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(
'b-general',

View File

@@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
@@ -100,6 +101,7 @@ export class BookingClearanceService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
@@ -174,7 +176,28 @@ export class BookingClearanceService {
}
const allApproved = await this.isClearanceFullyApproved(booking);
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. Backfill it here so already-stuck
// rows recover without a migration; idempotent (no-op once COMPLETED).
const paymentSettled = milestones.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (
paymentSettled &&
paymentSettled.status === 'PENDING' &&
(booking.paymentStatus === 'PAID' || booking.status === 'PAID')
) {
await this.workflowService.completeMilestoneForBooking(
bookingId,
'FREIGHT_PAYMENT_SETTLED',
);
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
@@ -414,6 +437,7 @@ export class BookingClearanceService {
},
userId,
);
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
return this.bookingsService.findById(bookingId);
@@ -441,6 +465,7 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}

View File

@@ -10,6 +10,7 @@ import type { Freight } from '@edr/types';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
import { ContractNotifierService } from './contract-notifier.service';
import { BookingRequest } from './entities/booking-request.entity';
import { Contract } from './entities/contract.entity';
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
@@ -26,6 +27,7 @@ export class BookingRequestService {
private readonly repo: BookingRequestRepository,
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
) {}
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
@@ -107,7 +109,7 @@ export class BookingRequestService {
};
const reference = await this.generateReference();
return this.repo.create({
const request = await this.repo.create({
reference,
contractId,
requestedByUserId: userId ?? null,
@@ -117,6 +119,8 @@ export class BookingRequestService {
requestedLines,
notes: dto.notes ?? null,
} as never);
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
return request;
}
listForContract(contractId: string): Promise<BookingRequest[]> {

View File

@@ -6,6 +6,7 @@ import {
CustomsRiskLevel,
MilestoneMetadata,
} from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Contract } from './entities/contract.entity';
import {
HANDOFF_MILESTONES,
@@ -85,10 +86,36 @@ export class ClearanceMilestoneService {
}
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
return this.repo.find({
const rows = await this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. getClearanceView backfills it, but
// the stepper reads its gating milestones straight from here, so heal here too.
// Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration.
const paymentSettled = rows.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (paymentSettled && paymentSettled.status === 'PENDING') {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, status: true, paymentStatus: true },
});
if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') {
await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED');
return this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
}
}
return rows;
}
/**

View File

@@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) {
contractsRepository as never,
milestoneService as never,
bookingsRepository as never,
{ clearanceReady: jest.fn() } as never, // notifier
);
return { service, milestoneService, contractsRepository, bookingsRepository };
}

View File

@@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { Booking } from '../bookings/entities/booking.entity';
import type { ClearanceMetaState } from './clearance-workflow.types';
import { metaFromBooking } from './clearance-workflow.types';
@@ -34,6 +35,7 @@ export class ClearanceWorkflowService {
private readonly contractsRepository: ContractsRepository,
private readonly milestoneService: ClearanceMilestoneService,
private readonly bookingsRepository: BookingsRepository,
private readonly notifier: BookingLifecycleNotifierService,
) {}
boundaryMilestone(tradeDirection: string): string {
@@ -264,6 +266,14 @@ export class ClearanceWorkflowService {
status: 'CLEARANCE_READY',
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} 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(

View File

@@ -119,12 +119,27 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
const isIntercity = contract.tradeDirection === 'DOMESTIC';
if (isIntercity && dto.scheduledDate) {
throw new BadRequestException(
'Intercity bookings do not pick a date — staff assign them to a passing train',
);
}
// Every other direction keeps the binding shipment day (the DTO field went
// optional only for intercity).
if (!isIntercity && !dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms) {
if (!generalCustoms && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,

View File

@@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
@@ -118,6 +119,7 @@ export class ContractClearanceService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -543,7 +545,9 @@ export class ContractClearanceService {
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(
@@ -674,6 +678,7 @@ export class ContractClearanceService {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
} as never);
this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
}
@@ -1009,6 +1014,7 @@ export class ContractClearanceService {
},
userId,
);
this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
}
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(
@@ -1118,6 +1126,7 @@ export class ContractClearanceService {
await this.workflowService.markReadyForBooking(contractId);
}
this.notifier.preClearanceFinalized(contract);
return this.contractsService.findById(contractId);
}

View File

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

View File

@@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
@@ -66,6 +67,7 @@ export class ContractTransitionService {
private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService,
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -79,7 +81,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} 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). */
@@ -93,7 +97,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} 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,
contractValidUntil: validUntil,
} 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, {
status: 'CHANGES_REQUESTED',
} 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> {
@@ -235,7 +245,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} 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. */
@@ -297,7 +309,11 @@ export class ContractTransitionService {
if (Object.keys(updates).length > 0) {
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(),
} as never);
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);
@@ -605,7 +623,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, updates as never);
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. */

View File

@@ -13,10 +13,12 @@ import {
UnauthorizedException,
UploadedFiles,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import {
@@ -242,6 +244,7 @@ export class ContractsController {
}
@Get('list-summary')
@BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view])
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
@ApiOkResponse({ type: ContractListSummaryDto })
findListSummary(@Query() filter: FilterContractDto) {
@@ -449,14 +452,25 @@ export class ContractsController {
}
@Post(':id/contract/sign')
@UseGuards(JwtGuard)
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
// Each staff signing role maps to the permission that step already requires;
// customers sign their own contract with no permission key.
const signRolePermission: Record<string, string> = {
STAFF: FREIGHT_PERMS.contracts.signStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
if (dto.role !== 'CUSTOMER') {
assertFreightPermission(user, signRolePermission[dto.role]);
}
return this.transitionService.sign(id, dto, {
signerUserId: user?.id ?? user?.sub,
signerUserId: user?.id,
});
}

View File

@@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.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 { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
@@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { BookingClearanceService } from './booking-clearance.service';
@@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
MinioModule,
SignaturesModule,
OtpModule,
NotificationsModule,
NotificationInboxModule,
CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// 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,
ContractsRepository,
ContractPricingService,
ContractNotifierService,
ContractTransitionService,
ContractClearanceService,
ClearanceWorkflowService,

View File

@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContractsRepository } from './contracts.repository';
@@ -110,6 +114,49 @@ export class ContractsService {
}
}
/**
* Every route must match the contract's declared trade direction as derived
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
* (e.g. an export lane on an import contract) are rejected for every kind.
*/
private async assertRoutesMatchDirection(
tradeDirection: string,
routes: CreateContractDto['routes'],
): Promise<void> {
const yardIds = [
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
];
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: yardIds.map((id) => ({ id })) });
const yardById = new Map(yards.map((y) => [y.id, y]));
for (const route of routes) {
const origin = yardById.get(route.originYardId);
const destination = yardById.get(route.destinationYardId);
if (!origin || !destination) {
throw new BadRequestException('Route references a yard that does not exist');
}
const derived = deriveTradeDirection(origin, destination);
if (derived !== tradeDirection) {
throw new BadRequestException(
`Route ${origin.label}${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
);
}
if (
derived === 'DOMESTIC' &&
(origin.country !== YardCountry.ETHIOPIA ||
destination.country !== YardCountry.ETHIOPIA)
) {
throw new BadRequestException(
`Route ${origin.label}${destination.label}: intercity service only runs between Ethiopian yards`,
);
}
}
}
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create(
dto: CreateContractDto,
@@ -144,6 +191,7 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
// Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null;
@@ -175,6 +223,13 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
@@ -360,6 +415,12 @@ export class ContractsService {
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
if (dto.routes) {
await this.assertRoutesMatchDirection(
dto.tradeDirection ?? existing.tradeDirection,
dto.routes,
);
}
const updates: Record<string, unknown> = {
contractKind,
@@ -385,6 +446,11 @@ export class ContractsService {
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null
@@ -501,6 +567,21 @@ export class ContractsService {
);
}
// Surface the staff "request changes" note so the portal can show the
// customer what to fix. Degrade to null on lookup failure — a missing note
// must never 500 a contract fetch.
if (contract.status === 'CHANGES_REQUESTED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'CHANGES_REQUESTED',
);
contract.latestChangeRequestNote = note?.body ?? null;
} catch {
contract.latestChangeRequestNote = null;
}
}
return contract;
}

View File

@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
@IsUUID()
contractRouteId?: string;
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
@ApiPropertyOptional({
description:
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
example: '2026-07-15',
})
@IsOptional()
@IsDateString()
scheduledDate!: string;
scheduledDate?: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional()

View File

@@ -260,4 +260,11 @@ export class Contract extends BaseEntity {
* ContractsRepository.attachClearancePhases for list responses. Not a column.
*/
clearancePhase?: string | null;
/**
* Body of the most recent CHANGES_REQUESTED review note, attached by
* ContractsService.findById so the portal can show the customer what staff
* asked them to fix. Lives in contract_review_notes, not a column here.
*/
latestChangeRequestNote?: string | null;
}

View File

@@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import { FilesService } from '../files/files.service';
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 { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
import {
@@ -53,6 +54,7 @@ export class GlOperationsService {
private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly billingService: BillingService,
private readonly notifier: BookingLifecycleNotifierService,
) {}
private get bookings() {
@@ -64,7 +66,11 @@ export class GlOperationsService {
}
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`);
return booking;
}
@@ -447,6 +453,7 @@ export class GlOperationsService {
void userId;
const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice could not be created.');
this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
return summary;
}
@@ -455,7 +462,7 @@ export class GlOperationsService {
bookingId: string,
file: Express.Multer.File,
): Promise<{ uploaded: boolean }> {
await this.getBooking(bookingId);
const booking = await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded');
const invoice = await this.billingService.findInvoice(
@@ -482,6 +489,7 @@ export class GlOperationsService {
code: 'final_invoice_slip',
file,
});
this.notifier.dutySlipUploadedToStaff(booking, 'final');
return { uploaded: true };
}
@@ -490,7 +498,7 @@ export class GlOperationsService {
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> {
await this.getBooking(bookingId);
const booking = await this.getBooking(bookingId);
const invoice = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking,
bookingId,
@@ -507,6 +515,7 @@ export class GlOperationsService {
);
}
await this.billingService.markInvoiceAsPaid(invoice.id);
this.notifier.finalInvoicePaid(booking);
}
void userId;
@@ -575,6 +584,7 @@ export class GlOperationsService {
},
userId,
);
this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
return { advised: true, skipped: false };
}
@@ -605,6 +615,7 @@ export class GlOperationsService {
booking.tradeDirection ?? 'IMPORT',
);
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
this.notifier.dutySlipUploadedToStaff(booking, 'second');
return { milestoneCompleted: true };
}

View File

@@ -8,8 +8,11 @@ import {
Body,
Query,
ParseUUIDPipe,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
@@ -65,6 +68,31 @@ export class DriversController {
return this.fleetHistory.getDriverHistory(id);
}
@Post(':id/documents')
@FleetManage()
@ApiConsumes('multipart/form-data')
@UseInterceptors(AnyFilesInterceptor())
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.driversService.uploadDocuments(id, files ?? []);
}
@Get(':id/documents')
@ApiOperation({ summary: "List a driver's documents" })
listDocuments(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.listDocuments(id);
}
@Delete(':id/documents/:fileId')
@FleetManage()
@ApiOperation({ summary: 'Delete a driver document' })
removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) {
return this.driversService.removeDocument(fileId);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a driver' })

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
import { FilesModule } from '../files/files.module';
@Module({
imports: [TypeOrmModule.forFeature([Driver])],
imports: [TypeOrmModule.forFeature([Driver]), FilesModule],
providers: [DriversService],
controllers: [DriversController],
exports: [DriversService],

View File

@@ -6,6 +6,11 @@ import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
import { FilesService } from '../files/files.service';
/** Resource + code the driver-documents upload area is stored under. */
const DRIVER_DOCS_RESOURCE = 'driver';
const DRIVER_DOCS_CODE = 'driver_docs';
@Injectable()
export class DriversService {
@@ -13,8 +18,37 @@ export class DriversService {
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly history: FleetHistoryService,
private readonly filesService: FilesService,
) {}
/** Upload one or more driver documents (code "driver_docs"). */
async uploadDocuments(driverId: string, files: Express.Multer.File[]) {
const driver = await this.driverRepo.findOneBy({ id: driverId });
if (!driver) throw new NotFoundException(`Driver ${driverId} not found`);
if (!files?.length) throw new BadRequestException('No files provided');
return Promise.all(
files.map((file) =>
this.filesService.upload({
resourceId: driverId,
resource: DRIVER_DOCS_RESOURCE,
code: DRIVER_DOCS_CODE,
file,
}),
),
);
}
/** List a driver's uploaded documents (code "driver_docs"). */
async listDocuments(driverId: string) {
const all = await this.filesService.findByResource(driverId, DRIVER_DOCS_RESOURCE);
return all.filter((f) => f.code === DRIVER_DOCS_CODE);
}
/** Delete a single driver document by file id. */
async removeDocument(fileId: string): Promise<void> {
await this.filesService.remove(fileId);
}
async create(dto: CreateDriverDto): Promise<Driver> {
if (dto.faydaVerified !== true) {
throw new BadRequestException(

View File

@@ -119,6 +119,11 @@ export class FilesService {
return record;
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource);
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -66,13 +66,37 @@ export class FirstMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
return this.billing.generateInvoice({
source: 'first_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: fm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -640,10 +640,24 @@ export class FirstMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// FIRST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.firstMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -0,0 +1,24 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
imei!: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string;
}
export class UpdateDeviceDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
/**
* A physical GPS tracker (GT06). Identified by IMEI, optionally bound to a
* vehicle. Carries the denormalized latest fix so the live map reads one row
* per device without scanning position history.
*/
@Entity({ name: 'gps_devices', schema: 'freight' })
@Index(['vehicleId'])
export class GpsDevice extends BaseEntity {
@Column({ name: 'imei', type: 'varchar', length: 20, unique: true })
imei!: string;
@Column({ name: 'name', type: 'varchar', nullable: true })
name?: string | null;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
/** ONLINE once a packet arrives; OFFLINE when stale (derived on read). */
@Column({ name: 'status', type: 'varchar', length: 16, default: 'REGISTERED' })
status!: string;
@Column({ name: 'last_seen_at', type: 'timestamptz', nullable: true })
lastSeenAt?: Date | null;
// ── Denormalized latest fix ──
@Column({ name: 'last_lat', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLat?: number | null;
@Column({ name: 'last_lng', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLng?: number | null;
@Column({ name: 'last_speed', type: 'numeric', precision: 6, scale: 2, nullable: true })
lastSpeed?: number | null;
@Column({ name: 'last_course', type: 'int', nullable: true })
lastCourse?: number | null;
@Column({ name: 'last_fix_at', type: 'timestamptz', nullable: true })
lastFixAt?: Date | null;
@Column({ name: 'voltage_level', type: 'int', nullable: true })
voltageLevel?: number | null;
@Column({ name: 'gsm_level', type: 'int', nullable: true })
gsmLevel?: number | null;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/** One GPS fix from a tracker (append-only history). */
@Entity({ name: 'gps_positions', schema: 'freight' })
@Index(['deviceId', 'gpsTime'])
@Index(['vehicleId', 'gpsTime'])
export class GpsPosition extends BaseEntity {
@Column({ name: 'device_id', type: 'uuid' })
deviceId!: string;
@Column({ name: 'imei', type: 'varchar', length: 20 })
imei!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@Column({ name: 'lat', type: 'numeric', precision: 10, scale: 6 })
lat!: number;
@Column({ name: 'lng', type: 'numeric', precision: 10, scale: 6 })
lng!: number;
@Column({ name: 'speed', type: 'numeric', precision: 6, scale: 2, default: 0 })
speed!: number;
@Column({ name: 'course', type: 'int', default: 0 })
course!: number;
@Column({ name: 'satellites', type: 'int', default: 0 })
satellites!: number;
@Column({ name: 'positioned', type: 'boolean', default: false })
positioned!: boolean;
/** Fix time reported by the device (UTC). */
@Column({ name: 'gps_time', type: 'timestamptz' })
gpsTime!: Date;
/** Non-zero when the fix came in via an alarm packet. */
@Column({ name: 'alarm', type: 'int', default: 0 })
alarm!: number;
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { GpsTrackingService } from './gps-tracking.service';
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
@FleetView()
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}
@Get('positions/latest')
@ApiOperation({ summary: 'Latest fix per device (live map feed)' })
latest() {
return this.gps.latest();
}
@Get('positions/:vehicleId/history')
@ApiOperation({ summary: 'Position history for a vehicle' })
history(
@Param('vehicleId', ParseUUIDPipe) vehicleId: string,
@Query('limit') limit?: string,
) {
return this.gps.history(vehicleId, limit ? parseInt(limit, 10) : undefined);
}
@Get('devices')
@ApiOperation({ summary: 'List GPS trackers' })
listDevices() {
return this.gps.listDevices();
}
@Post('devices')
@FleetManage()
@ApiOperation({ summary: 'Register a GPS tracker' })
register(@Body() dto: RegisterDeviceDto) {
return this.gps.registerDevice(dto);
}
@Patch('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
return this.gps.updateDevice(id, dto);
}
@Delete('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Delete a GPS tracker' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.gps.removeDevice(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsTrackingService } from './gps-tracking.service';
import { GpsTrackingController } from './gps-tracking.controller';
import { Gt06Server } from './gt06/gt06.server';
@Module({
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
controllers: [GpsTrackingController],
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server],
exports: [GpsTrackingService],
})
export class GpsTrackingModule {}

View File

@@ -0,0 +1,29 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
@Injectable()
export class GpsDeviceRepository extends BaseRepository<GpsDevice> {
constructor(
@InjectRepository(GpsDevice) repository: Repository<GpsDevice>,
) {
super(repository);
}
findByImei(imei: string): Promise<GpsDevice | null> {
return this.repository.findOne({ where: { imei } });
}
}
@Injectable()
export class GpsPositionRepository extends BaseRepository<GpsPosition> {
constructor(
@InjectRepository(GpsPosition) repository: Repository<GpsPosition>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,124 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsDevice } from './entities/gps-device.entity';
import { Gt06Gps, Gt06Status } from './gt06/gt06.codec';
/** A device is considered ONLINE if seen within this window. */
const ONLINE_WINDOW_MS = 5 * 60 * 1000;
@Injectable()
export class GpsTrackingService {
private readonly logger = new Logger(GpsTrackingService.name);
constructor(
private readonly devices: GpsDeviceRepository,
private readonly positions: GpsPositionRepository,
) {}
private isOnline(d: GpsDevice): boolean {
return Boolean(d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_WINDOW_MS);
}
/** Find the device for an IMEI, auto-registering it on first contact. */
private async ensureDevice(imei: string): Promise<GpsDevice> {
const existing = await this.devices.findByImei(imei);
if (existing) return existing;
this.logger.log(`Auto-registering new GPS tracker ${imei}`);
return this.devices.create({ imei, status: 'REGISTERED', lastSeenAt: new Date() });
}
// ── Ingestion (called by the TCP server) ──
async handleLogin(imei: string): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' });
}
async handleHeartbeat(imei: string, status: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, {
lastSeenAt: new Date(),
status: 'ONLINE',
voltageLevel: status.voltageLevel,
gsmLevel: status.gsmLevel,
});
}
async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
const now = new Date();
await this.devices.update(device.id, {
lastSeenAt: now,
status: 'ONLINE',
lastLat: gps.latitude,
lastLng: gps.longitude,
lastSpeed: gps.speed,
lastCourse: gps.course,
lastFixAt: new Date(gps.time),
...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}),
});
await this.positions.create({
deviceId: device.id,
imei,
vehicleId: device.vehicleId ?? null,
lat: gps.latitude,
lng: gps.longitude,
speed: gps.speed,
course: gps.course,
satellites: gps.satellites,
positioned: gps.positioned,
gpsTime: new Date(gps.time),
alarm,
});
}
// ── Queries / management (REST) ──
private decorate(d: GpsDevice) {
return { ...d, online: this.isOnline(d) };
}
async listDevices() {
const rows = await this.devices.findAll({ relations: { vehicle: true }, order: { createdAt: 'DESC' } });
return rows.map((d) => this.decorate(d));
}
/** Live map feed — devices that have at least one fix. */
async latest() {
const rows = await this.devices.findAll({ relations: { vehicle: true } });
return rows.filter((d) => d.lastLat != null && d.lastLng != null).map((d) => this.decorate(d));
}
async history(vehicleId: string, limit = 200) {
return this.positions.findAll({
where: { vehicleId },
order: { gpsTime: 'DESC' },
take: Math.min(limit, 1000),
});
}
async registerDevice(dto: { imei: string; name?: string; vehicleId?: string | null }) {
const existing = await this.devices.findByImei(dto.imei);
if (existing) throw new BadRequestException(`A device with IMEI ${dto.imei} already exists`);
return this.devices.create({
imei: dto.imei,
name: dto.name ?? null,
vehicleId: dto.vehicleId ?? null,
status: 'REGISTERED',
});
}
async updateDevice(id: string, dto: { name?: string; vehicleId?: string | null }) {
const updated = await this.devices.update(id, {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
if (!updated) throw new NotFoundException(`GPS device ${id} not found`);
return updated;
}
async removeDevice(id: string): Promise<void> {
await this.devices.softDelete(id);
}
}

View File

@@ -0,0 +1,207 @@
/**
* GT06 GPS-tracker protocol codec.
*
* Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A
* `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over
* len..serial (inclusive) and equals the 2 crc bytes.
*/
const START = 0x7878;
const STOP = 0x0d0a;
export const GT06_PROTOCOL = {
LOGIN: 0x01,
LOCATION: 0x12,
HEARTBEAT: 0x13,
STRING: 0x15,
ALARM: 0x16,
ADDRESS_BY_PHONE: 0x1a,
SERVER_COMMAND: 0x80,
} as const;
/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */
export function crcItu(bytes: Buffer): number {
let fcs = 0xffff;
for (const b of bytes) {
fcs ^= b;
for (let i = 0; i < 8; i++) {
fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1;
}
}
return (~fcs) & 0xffff;
}
export interface Gt06Gps {
time: string; // ISO (UTC)
satellites: number;
latitude: number;
longitude: number;
speed: number; // km/h
course: number; // 0-360
positioned: boolean;
}
export interface Gt06Lbs {
mcc: number;
mnc: number;
lac: number;
cellId: number;
}
export interface Gt06Status {
terminalInfo: number;
voltageLevel: number;
gsmLevel: number;
alarm: number; // former byte of alarm/language
charging: boolean;
accOn: boolean;
gpsTracking: boolean;
oilCut: boolean;
}
export type Gt06Packet =
| { type: 'login'; protocol: number; serial: number; imei: string }
| { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs }
| { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status }
| { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status }
| { type: 'unknown'; protocol: number; serial: number };
/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */
function decodeImei(buf: Buffer): string {
return buf.toString('hex').replace(/^0/, '');
}
function decodeDateTime(buf: Buffer, off: number): string {
const year = 2000 + buf[off];
const month = buf[off + 1];
const day = buf[off + 2];
const hour = buf[off + 3];
const min = buf[off + 4];
const sec = buf[off + 5];
return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString();
}
/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */
function rawToDegrees(raw: number): number {
return raw / 30000 / 60;
}
function decodeGps(buf: Buffer, off: number): Gt06Gps {
const time = decodeDateTime(buf, off);
const lenSat = buf[off + 6];
const satellites = lenSat & 0x0f;
const latRaw = buf.readUInt32BE(off + 7);
const lonRaw = buf.readUInt32BE(off + 11);
const speed = buf[off + 15];
const cs = buf.readUInt16BE(off + 16);
const hi = (cs >> 8) & 0xff;
const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4
const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West)
const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North)
const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2
let latitude = rawToDegrees(latRaw);
let longitude = rawToDegrees(lonRaw);
if (!isNorth) latitude = -latitude;
if (isWest) longitude = -longitude;
return { time, satellites, latitude, longitude, speed, course, positioned };
}
function decodeStatus(buf: Buffer, off: number): Gt06Status {
const terminalInfo = buf[off];
const voltageLevel = buf[off + 1];
const gsmLevel = buf[off + 2];
const alarm = buf[off + 3]; // alarm/language former byte
return {
terminalInfo,
voltageLevel,
gsmLevel,
alarm,
oilCut: Boolean(terminalInfo & 0x80),
gpsTracking: Boolean(terminalInfo & 0x40),
charging: Boolean(terminalInfo & 0x04),
accOn: Boolean(terminalInfo & 0x02),
};
}
function decodeLbs(buf: Buffer, off: number): Gt06Lbs {
return {
mcc: buf.readUInt16BE(off),
mnc: buf[off + 2],
lac: buf.readUInt16BE(off + 3),
cellId: buf.readUIntBE(off + 5, 3),
};
}
function decodeFrame(frame: Buffer): Gt06Packet | null {
// frame = 78 78 len ...content... serial(2) crc(2) 0D 0A
const len = frame[2];
const protocol = frame[3];
const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2)
const serial = frame.readUInt16BE(serialOff);
const contentOff = 4; // start of content (after protocol)
switch (protocol) {
case GT06_PROTOCOL.LOGIN:
return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) };
case GT06_PROTOCOL.LOCATION:
return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) };
case GT06_PROTOCOL.HEARTBEAT:
return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) };
case GT06_PROTOCOL.ALARM: {
const gps = decodeGps(frame, contentOff);
// content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2)
const lbs = decodeLbs(frame, contentOff + 18 + 1);
const status = decodeStatus(frame, contentOff + 18 + 1 + 8);
return { type: 'alarm', protocol, serial, gps, lbs, status };
}
default:
return { type: 'unknown', protocol, serial };
}
}
/**
* Pull all complete frames out of a stream buffer. Returns the decoded packets
* (skipping CRC-failed ones) and the trailing bytes that form a partial frame.
*/
export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } {
const packets: Gt06Packet[] = [];
let i = 0;
while (i + 5 <= buffer.length) {
if (buffer.readUInt16BE(i) !== START) {
i += 1; // resync
continue;
}
const len = buffer[i + 2];
const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop
if (i + frameLen > buffer.length) break; // incomplete
const frame = buffer.subarray(i, i + frameLen);
if (frame.readUInt16BE(frameLen - 2) === STOP) {
// CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3.
const crcCalc = crcItu(frame.subarray(2, frameLen - 4));
const crcRecv = frame.readUInt16BE(frameLen - 4);
if (crcCalc === crcRecv) {
const pkt = decodeFrame(frame);
if (pkt) packets.push(pkt);
}
i += frameLen;
} else {
i += 1; // bad frame, resync
}
}
return { packets, rest: buffer.subarray(i) };
}
/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */
export function buildAck(protocol: number, serial: number): Buffer {
const body = Buffer.alloc(3); // protocol + serial(2)
body[0] = protocol;
body.writeUInt16BE(serial, 1);
const len = body.length + 2; // + crc(2)
const forCrc = Buffer.concat([Buffer.from([len]), body]);
const crc = crcItu(forCrc);
return Buffer.concat([
Buffer.from([0x78, 0x78, len]),
body,
Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]),
]);
}

View File

@@ -0,0 +1,96 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import * as net from 'net';
import { GpsTrackingService } from '../gps-tracking.service';
import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec';
interface Session {
buffer: Buffer;
imei: string | null;
}
const MAX_BUFFER = 64 * 1024;
/**
* Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login
* (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via
* {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps
* the connection alive. Disabled when GT06_TCP_PORT=0.
*/
@Injectable()
export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(Gt06Server.name);
private server?: net.Server;
private readonly sessions = new Map<net.Socket, Session>();
constructor(private readonly gps: GpsTrackingService) {}
onApplicationBootstrap(): void {
const port = Number(process.env.GT06_TCP_PORT ?? 5023);
if (!port) {
this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)');
return;
}
this.server = net.createServer((socket) => this.onConnection(socket));
this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`));
this.server.listen(port, () => this.logger.log(`GT06 GPS tracker listener on tcp/${port}`));
}
onModuleDestroy(): void {
for (const socket of this.sessions.keys()) socket.destroy();
this.sessions.clear();
this.server?.close();
}
private onConnection(socket: net.Socket): void {
this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null });
socket.on('data', (chunk) => void this.onData(socket, chunk));
socket.on('error', () => this.sessions.delete(socket));
socket.on('close', () => this.sessions.delete(socket));
}
private async onData(socket: net.Socket, chunk: Buffer): Promise<void> {
const session = this.sessions.get(socket);
if (!session) return;
session.buffer = Buffer.concat([session.buffer, chunk]);
if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage
const { packets, rest } = parseStream(session.buffer);
session.buffer = rest;
for (const pkt of packets) {
try {
await this.handle(socket, session, pkt);
} catch (err) {
this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`);
}
}
}
private async handle(
socket: net.Socket,
session: Session,
pkt: ReturnType<typeof parseStream>['packets'][number],
): Promise<void> {
switch (pkt.type) {
case 'login':
session.imei = pkt.imei;
await this.gps.handleLogin(pkt.imei);
socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial));
break;
case 'heartbeat':
if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial));
break;
case 'location':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps);
break;
case 'alarm':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial));
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,48 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity';
export class CreateIncidentDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsEnum(IncidentType)
type!: IncidentType;
@IsEnum(IncidentSeverity)
severity!: IncidentSeverity;
@IsDateString()
occurredAt!: string;
@IsOptional()
@IsString()
location?: string;
@IsString()
description!: string;
@IsOptional()
@IsNumber()
damageEstimate?: number;
@IsOptional()
@IsEnum(IncidentStatus)
status?: IncidentStatus;
@IsOptional()
@IsString()
insuranceClaimNumber?: string;
@IsOptional()
@IsString()
reportedBy?: string;
}

View File

@@ -0,0 +1,52 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity';
export class UpdateIncidentDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsOptional()
@IsEnum(IncidentType)
type?: IncidentType;
@IsOptional()
@IsEnum(IncidentSeverity)
severity?: IncidentSeverity;
@IsOptional()
@IsDateString()
occurredAt?: string;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsNumber()
damageEstimate?: number;
@IsOptional()
@IsEnum(IncidentStatus)
status?: IncidentStatus;
@IsOptional()
@IsString()
insuranceClaimNumber?: string;
@IsOptional()
@IsString()
reportedBy?: string;
}

View File

@@ -0,0 +1,76 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { Driver } from '../../drivers/entities/driver.entity';
export enum IncidentType {
ACCIDENT = 'ACCIDENT',
BREAKDOWN = 'BREAKDOWN',
TRAFFIC_VIOLATION = 'TRAFFIC_VIOLATION',
THEFT = 'THEFT',
OTHER = 'OTHER',
}
export enum IncidentSeverity {
MINOR = 'MINOR',
MODERATE = 'MODERATE',
MAJOR = 'MAJOR',
CRITICAL = 'CRITICAL',
}
export enum IncidentStatus {
REPORTED = 'REPORTED',
UNDER_REVIEW = 'UNDER_REVIEW',
CLAIM_FILED = 'CLAIM_FILED',
RESOLVED = 'RESOLVED',
CLOSED = 'CLOSED',
}
@Entity({ name: 'incidents', schema: 'freight' })
@Index(['driverId', 'occurredAt'])
@Index(['vehicleId', 'occurredAt'])
export class Incident extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: true })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string;
@ManyToOne(() => Driver, { eager: false, nullable: true })
@JoinColumn({ name: 'driver_id' })
driver?: Driver;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string;
@Column({ name: 'type', type: 'varchar' })
type!: IncidentType;
@Column({ name: 'severity', type: 'varchar' })
severity!: IncidentSeverity;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'location', type: 'varchar', nullable: true })
location?: string;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'damage_estimate', type: 'numeric', precision: 14, scale: 2, nullable: true })
damageEstimate?: number;
@Column({ name: 'status', type: 'varchar', default: IncidentStatus.REPORTED })
status!: IncidentStatus;
@Column({ name: 'insurance_claim_number', type: 'varchar', nullable: true })
insuranceClaimNumber?: string;
@Column({ name: 'reported_by', type: 'varchar', nullable: true })
reportedBy?: string;
}

View File

@@ -0,0 +1,69 @@
import {
Controller,
Post,
Get,
Patch,
Delete,
Body,
Param,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { IncidentsService } from './incidents.service';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
import { IncidentStatus, IncidentType } from './entities/incident.entity';
@ApiTags('Accident & Incident Management')
@Controller('incidents')
export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {}
@Post()
@ApiOperation({ summary: 'Report an incident' })
async create(@Body() dto: CreateIncidentDto) {
return this.incidentsService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List incidents (optionally filtered)' })
async findAll(
@Query('vehicleId') vehicleId?: string,
@Query('driverId') driverId?: string,
@Query('status') status?: IncidentStatus,
@Query('type') type?: IncidentType,
) {
return this.incidentsService.findAll({ vehicleId, driverId, status, type });
}
@Get('driver/:driverId/stats')
@ApiOperation({ summary: 'Get incident statistics for a driver' })
async statsForDriver(@Param('driverId') driverId: string) {
return this.incidentsService.statsForDriver(driverId);
}
@Get('driver/:driverId')
@ApiOperation({ summary: 'List incidents for a driver (incident history)' })
async findByDriver(@Param('driverId') driverId: string) {
return this.incidentsService.findByDriver(driverId);
}
@Get(':id')
@ApiOperation({ summary: 'Get an incident by id' })
async findById(@Param('id') id: string) {
return this.incidentsService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update an incident' })
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
return this.incidentsService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an incident' })
async remove(@Param('id') id: string) {
await this.incidentsService.remove(id);
return { success: true };
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Incident } from './entities/incident.entity';
import { IncidentsService } from './incidents.service';
import { IncidentsRepository } from './incidents.repository';
import { IncidentsController } from './incidents.controller';
@Module({
imports: [TypeOrmModule.forFeature([Incident])],
providers: [IncidentsService, IncidentsRepository],
controllers: [IncidentsController],
exports: [IncidentsService],
})
export class IncidentsModule {}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository } from 'typeorm';
import { Incident } from './entities/incident.entity';
@Injectable()
export class IncidentsRepository extends BaseRepository<Incident> {
constructor(
@InjectRepository(Incident)
incidentRepository: Repository<Incident>,
) {
super(incidentRepository);
}
}

View File

@@ -0,0 +1,95 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { IncidentsRepository } from './incidents.repository';
import {
Incident,
IncidentStatus,
IncidentType,
} from './entities/incident.entity';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
export interface IncidentFilter {
vehicleId?: string;
driverId?: string;
status?: IncidentStatus;
type?: IncidentType;
}
export interface DriverIncidentStats {
total: number;
byType: Record<string, number>;
lastIncidentAt: Date | null;
}
@Injectable()
export class IncidentsService {
constructor(private readonly incidentsRepository: IncidentsRepository) {}
async create(dto: CreateIncidentDto): Promise<Incident> {
return this.incidentsRepository.create({
...dto,
occurredAt: new Date(dto.occurredAt),
});
}
async findAll(filter: IncidentFilter = {}): Promise<Incident[]> {
const where: FindOptionsWhere<Incident> = {};
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
if (filter.driverId) where.driverId = filter.driverId;
if (filter.status) where.status = filter.status;
if (filter.type) where.type = filter.type;
return this.incidentsRepository.findAll({
where,
order: { occurredAt: 'DESC' },
});
}
async findByDriver(driverId: string): Promise<Incident[]> {
return this.incidentsRepository.findAll({
where: { driverId },
order: { occurredAt: 'DESC' },
});
}
async findById(id: string): Promise<Incident> {
const incident = await this.incidentsRepository.findById(id);
if (!incident) {
throw new NotFoundException(`Incident ${id} not found`);
}
return incident;
}
async update(id: string, dto: UpdateIncidentDto): Promise<Incident> {
await this.findById(id);
const updated = await this.incidentsRepository.update(id, {
...dto,
occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : undefined,
});
return updated!;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.incidentsRepository.softDelete(id);
}
async statsForDriver(driverId: string): Promise<DriverIncidentStats> {
const incidents = await this.incidentsRepository.findAll({
where: { driverId },
order: { occurredAt: 'DESC' },
});
const byType: Record<string, number> = {};
for (const incident of incidents) {
byType[incident.type] = (byType[incident.type] || 0) + 1;
}
return {
total: incidents.length,
byType,
lastIncidentAt: incidents.length > 0 ? incidents[0].occurredAt : null,
};
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -63,6 +63,30 @@ export class LastMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
@@ -70,7 +94,7 @@ export class LastMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: lm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -521,10 +521,24 @@ export class LastMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// LAST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.lastMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -0,0 +1,171 @@
import {
IsUUID,
IsString,
IsDateString,
IsNumber,
IsInt,
IsOptional,
IsEnum,
Min,
} from 'class-validator';
import { WorkOrderStatus, WorkOrderPriority } from '../entities/work-order.entity';
export class CreateWorkOrderDto {
@IsUUID()
vehicleId!: string;
@IsString()
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(WorkOrderStatus)
status?: WorkOrderStatus;
@IsOptional()
@IsEnum(WorkOrderPriority)
priority?: WorkOrderPriority;
@IsOptional()
@IsString()
assignedTo?: string;
@IsOptional()
@IsDateString()
openedAt?: string;
@IsOptional()
@IsDateString()
closedAt?: string;
@IsOptional()
@IsNumber()
laborCost?: number;
@IsOptional()
@IsNumber()
partsCost?: number;
}
export class UpdateWorkOrderDto {
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(WorkOrderStatus)
status?: WorkOrderStatus;
@IsOptional()
@IsEnum(WorkOrderPriority)
priority?: WorkOrderPriority;
@IsOptional()
@IsString()
assignedTo?: string;
@IsOptional()
@IsDateString()
closedAt?: string;
@IsOptional()
@IsNumber()
laborCost?: number;
@IsOptional()
@IsNumber()
partsCost?: number;
}
export class CreatePartDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0)
quantityInStock?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsNumber()
unitCost?: number;
@IsOptional()
@IsString()
location?: string;
}
export class UpdatePartDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
sku?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsInt()
@Min(0)
quantityInStock?: number;
@IsOptional()
@IsInt()
@Min(0)
reorderLevel?: number;
@IsOptional()
@IsNumber()
unitCost?: number;
@IsOptional()
@IsString()
location?: string;
}
export class CreateWarrantyDto {
@IsUUID()
vehicleId!: string;
@IsString()
component!: string;
@IsOptional()
@IsString()
provider?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsDateString()
expiryDate!: string;
@IsOptional()
@IsString()
coverageNotes?: string;
}

View File

@@ -0,0 +1,27 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, Index } from 'typeorm';
@Entity({ name: 'parts', schema: 'freight' })
@Index(['category'])
export class Part extends BaseEntity {
@Column({ name: 'name', type: 'varchar' })
name!: string;
@Column({ name: 'sku', type: 'varchar', nullable: true })
sku?: string;
@Column({ name: 'category', type: 'varchar', nullable: true })
category?: string; // includes 'TIRE' — doubles as tire inventory
@Column({ name: 'quantity_in_stock', type: 'int', default: 0 })
quantityInStock!: number;
@Column({ name: 'reorder_level', type: 'int', default: 0 })
reorderLevel!: number;
@Column({ name: 'unit_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
unitCost?: number;
@Column({ name: 'location', type: 'varchar', nullable: true })
location?: string;
}

View File

@@ -0,0 +1,29 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'warranties', schema: 'freight' })
@Index(['vehicleId', 'expiryDate'])
export class Warranty extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'component', type: 'varchar' })
component!: string;
@Column({ name: 'provider', type: 'varchar', nullable: true })
provider?: string;
@Column({ name: 'start_date', type: 'date', nullable: true })
startDate?: string;
@Column({ name: 'expiry_date', type: 'date' })
expiryDate!: string;
@Column({ name: 'coverage_notes', type: 'text', nullable: true })
coverageNotes?: string;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum WorkOrderStatus {
OPEN = 'OPEN',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
export enum WorkOrderPriority {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
URGENT = 'URGENT',
}
@Entity({ name: 'work_orders', schema: 'freight' })
@Index(['vehicleId', 'status'])
export class WorkOrder extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'title', type: 'varchar' })
title!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string;
@Column({ name: 'status', type: 'varchar', default: WorkOrderStatus.OPEN })
status!: WorkOrderStatus;
@Column({ name: 'priority', type: 'varchar', default: WorkOrderPriority.MEDIUM })
priority!: WorkOrderPriority;
@Column({ name: 'assigned_to', type: 'varchar', nullable: true })
assignedTo?: string;
@Column({ name: 'opened_at', type: 'timestamptz' })
openedAt!: Date;
@Column({ name: 'closed_at', type: 'timestamptz', nullable: true })
closedAt?: Date;
@Column({ name: 'labor_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
laborCost?: number;
@Column({ name: 'parts_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
partsCost?: number;
}

View File

@@ -0,0 +1,99 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
@Injectable()
export class MaintenanceDepthService {
constructor(
private readonly workOrderRepository: WorkOrderRepository,
private readonly partRepository: PartRepository,
private readonly warrantyRepository: WarrantyRepository,
) {}
// ---- Work Orders ----
async createWorkOrder(dto: CreateWorkOrderDto): Promise<WorkOrder> {
return this.workOrderRepository.create({
...dto,
openedAt: dto.openedAt ? new Date(dto.openedAt) : new Date(),
closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined,
});
}
async findWorkOrders(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
return this.workOrderRepository.findFiltered(filters);
}
async findWorkOrderById(id: string): Promise<WorkOrder> {
const workOrder = await this.workOrderRepository.findById(id);
if (!workOrder) throw new NotFoundException(`Work order ${id} not found`);
return workOrder;
}
async updateWorkOrder(id: string, dto: UpdateWorkOrderDto): Promise<WorkOrder> {
await this.findWorkOrderById(id);
const updated = await this.workOrderRepository.update(id, {
...dto,
closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined,
});
return updated!;
}
async deleteWorkOrder(id: string): Promise<{ id: string; deleted: boolean }> {
await this.findWorkOrderById(id);
await this.workOrderRepository.softDelete(id);
return { id, deleted: true };
}
// ---- Parts / Tires ----
async createPart(dto: CreatePartDto): Promise<Part> {
return this.partRepository.create({ ...dto });
}
async findParts(filters: { category?: string; lowStock?: boolean }) {
return this.partRepository.findFiltered(filters);
}
async updatePart(id: string, dto: UpdatePartDto): Promise<Part> {
const part = await this.partRepository.findById(id);
if (!part) throw new NotFoundException(`Part ${id} not found`);
const updated = await this.partRepository.update(id, { ...dto });
return updated!;
}
async deletePart(id: string): Promise<{ id: string; deleted: boolean }> {
const part = await this.partRepository.findById(id);
if (!part) throw new NotFoundException(`Part ${id} not found`);
await this.partRepository.softDelete(id);
return { id, deleted: true };
}
// ---- Warranties ----
async createWarranty(dto: CreateWarrantyDto): Promise<Warranty> {
return this.warrantyRepository.create({ ...dto });
}
async findWarranties(filters: { vehicleId?: string }) {
return this.warrantyRepository.findFiltered(filters);
}
async deleteWarranty(id: string): Promise<{ id: string; deleted: boolean }> {
const warranty = await this.warrantyRepository.findById(id);
if (!warranty) throw new NotFoundException(`Warranty ${id} not found`);
await this.warrantyRepository.softDelete(id);
return { id, deleted: true };
}
}

View File

@@ -1,12 +1,24 @@
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
CreatePartDto,
UpdatePartDto,
CreateWarrantyDto,
} from './dto/create-maintenance-depth.dto';
import { WorkOrderStatus } from './entities/work-order.entity';
@ApiTags('Maintenance Management')
@Controller('maintenance')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
constructor(
private readonly maintenanceService: MaintenanceService,
private readonly maintenanceDepthService: MaintenanceDepthService,
) {}
@Post('schedules')
@ApiOperation({ summary: 'Schedule maintenance' })
@@ -49,4 +61,91 @@ export class MaintenanceController {
async getStats(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
}
// ---- Work Orders ----
@Post('work-orders')
@ApiOperation({ summary: 'Create work order' })
async createWorkOrder(@Body() dto: CreateWorkOrderDto) {
return this.maintenanceDepthService.createWorkOrder(dto);
}
@Get('work-orders')
@ApiOperation({ summary: 'List work orders' })
async listWorkOrders(
@Query('vehicleId') vehicleId?: string,
@Query('status') status?: WorkOrderStatus,
) {
return this.maintenanceDepthService.findWorkOrders({ vehicleId, status });
}
@Get('work-orders/:id')
@ApiOperation({ summary: 'Get work order' })
async getWorkOrder(@Param('id') id: string) {
return this.maintenanceDepthService.findWorkOrderById(id);
}
@Patch('work-orders/:id')
@ApiOperation({ summary: 'Update work order' })
async updateWorkOrder(@Param('id') id: string, @Body() dto: UpdateWorkOrderDto) {
return this.maintenanceDepthService.updateWorkOrder(id, dto);
}
@Delete('work-orders/:id')
@ApiOperation({ summary: 'Delete work order' })
async deleteWorkOrder(@Param('id') id: string) {
return this.maintenanceDepthService.deleteWorkOrder(id);
}
// ---- Parts / Tires ----
@Post('parts')
@ApiOperation({ summary: 'Create part' })
async createPart(@Body() dto: CreatePartDto) {
return this.maintenanceDepthService.createPart(dto);
}
@Get('parts')
@ApiOperation({ summary: 'List parts / tire inventory' })
async listParts(
@Query('category') category?: string,
@Query('lowStock') lowStock?: string,
) {
return this.maintenanceDepthService.findParts({
category,
lowStock: lowStock === 'true',
});
}
@Patch('parts/:id')
@ApiOperation({ summary: 'Update part' })
async updatePart(@Param('id') id: string, @Body() dto: UpdatePartDto) {
return this.maintenanceDepthService.updatePart(id, dto);
}
@Delete('parts/:id')
@ApiOperation({ summary: 'Delete part' })
async deletePart(@Param('id') id: string) {
return this.maintenanceDepthService.deletePart(id);
}
// ---- Warranties ----
@Post('warranties')
@ApiOperation({ summary: 'Create warranty' })
async createWarranty(@Body() dto: CreateWarrantyDto) {
return this.maintenanceDepthService.createWarranty(dto);
}
@Get('warranties')
@ApiOperation({ summary: 'List warranties' })
async listWarranties(@Query('vehicleId') vehicleId?: string) {
return this.maintenanceDepthService.findWarranties({ vehicleId });
}
@Delete('warranties/:id')
@ApiOperation({ summary: 'Delete warranty' })
async deleteWarranty(@Param('id') id: string) {
return this.maintenanceDepthService.deleteWarranty(id);
}
}

View File

@@ -2,14 +2,30 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { WorkOrder } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { MaintenanceRepository } from './maintenance.repository';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { MaintenanceController } from './maintenance.controller';
@Module({
imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])],
providers: [MaintenanceService, MaintenanceRepository],
imports: [
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
],
providers: [
MaintenanceService,
MaintenanceDepthService,
MaintenanceRepository,
WorkOrderRepository,
PartRepository,
WarrantyRepository,
],
controllers: [MaintenanceController],
exports: [MaintenanceService],
exports: [MaintenanceService, MaintenanceDepthService],
})
export class MaintenanceModule {}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository } from 'typeorm';
import { Part } from './entities/part.entity';
@Injectable()
export class PartRepository extends BaseRepository<Part> {
constructor(
@InjectRepository(Part)
private readonly partRepository: Repository<Part>,
) {
super(partRepository);
}
async findFiltered(filters: { category?: string; lowStock?: boolean }) {
const qb = this.partRepository.createQueryBuilder('part');
if (filters.category) {
qb.andWhere('part.category = :category', { category: filters.category });
}
if (filters.lowStock) {
qb.andWhere('part.quantityInStock <= part.reorderLevel');
}
qb.orderBy('part.name', 'ASC');
return qb.getMany();
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { Warranty } from './entities/warranty.entity';
@Injectable()
export class WarrantyRepository extends BaseRepository<Warranty> {
constructor(
@InjectRepository(Warranty)
private readonly warrantyRepository: Repository<Warranty>,
) {
super(warrantyRepository);
}
async findFiltered(filters: { vehicleId?: string }) {
const where: FindOptionsWhere<Warranty> = {};
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
return this.warrantyRepository.find({
where,
order: { expiryDate: 'ASC' },
});
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, FindOptionsWhere } from 'typeorm';
import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity';
@Injectable()
export class WorkOrderRepository extends BaseRepository<WorkOrder> {
constructor(
@InjectRepository(WorkOrder)
private readonly workOrderRepository: Repository<WorkOrder>,
) {
super(workOrderRepository);
}
async findFiltered(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
const where: FindOptionsWhere<WorkOrder> = {};
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
if (filters.status) where.status = filters.status;
return this.workOrderRepository.find({
where,
order: { openedAt: 'DESC' },
});
}
}

View File

@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator";
export class ListNotificationsQueryDto {
@ApiPropertyOptional({
description: "Filter by read state. Omit to return all.",
})
@IsOptional()
@Transform(({ value }) =>
value === "true" ? true : value === "false" ? false : value,
)
@IsBoolean()
isRead?: boolean;
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,64 @@
import { BaseEntity } from "@edr/api-common";
import {
NotificationAudience,
NotificationChannelsSent,
NotificationPriority,
NotificationType,
} from "@edr/types";
import { Column, Entity, Index } from "typeorm";
/**
* A single persisted in-app notification addressed to one IAM user. Producers
* fan a logical notification out to N recipients by inserting one row per
* resolved user id (see NotificationInboxService.notify).
*/
@Entity({ schema: "freight", name: "notifications" })
@Index("IDX_NOTIFICATIONS_RECIPIENT_UNREAD", ["recipientUserId", "isRead"])
@Index("IDX_NOTIFICATIONS_RECIPIENT_CREATED", ["recipientUserId", "createdAt"])
export class Notification extends BaseEntity {
@Column({ name: "recipient_user_id", type: "uuid" })
recipientUserId!: string;
@Column({ name: "audience", type: "varchar", length: 20 })
audience!: NotificationAudience;
@Column({
name: "type",
type: "varchar",
length: 48,
default: NotificationType.GENERIC,
})
type!: NotificationType;
@Column({ name: "title", type: "varchar", length: 200 })
title!: string;
@Column({ name: "body", type: "text" })
body!: string;
/** Deep-link path within the app the item points to (e.g. `/contracts/:id`). */
@Column({ name: "link", type: "varchar", nullable: true })
link?: string | null;
/** Arbitrary structured payload (bookingId, invoiceId, contractId, …). */
@Column({ name: "data", type: "jsonb", nullable: true })
data?: Record<string, unknown> | null;
@Column({
name: "priority",
type: "varchar",
length: 12,
default: NotificationPriority.NORMAL,
})
priority!: NotificationPriority;
@Column({ name: "is_read", type: "boolean", default: false })
isRead!: boolean;
@Column({ name: "read_at", type: "timestamptz", nullable: true })
readAt?: Date | null;
/** Per-channel fan-out outcome for HIGH-priority items (email/SMS). */
@Column({ name: "channels_sent", type: "jsonb", nullable: true })
channelsSent?: NotificationChannelsSent | null;
}

View File

@@ -0,0 +1,79 @@
import { CurrentUser } from "@edr/api-common";
import {
NotificationAudience,
NotificationPriority,
NotificationType,
} from "@edr/types";
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
import { NotificationInboxService } from "./notification-inbox.service";
@ApiTags("notifications")
@Controller("notifications")
export class NotificationInboxController {
constructor(private readonly service: NotificationInboxService) {}
@Get()
@ApiOperation({ summary: "List my notifications (paginated, newest first)" })
list(
@CurrentUser() user: AuthUserPayload,
@Query() query: ListNotificationsQueryDto,
) {
return this.service.list(resolveAuthUserId(user), query);
}
@Get("unread-count")
@ApiOperation({ summary: "Count my unread notifications" })
unreadCount(@CurrentUser() user: AuthUserPayload) {
return this.service.unreadCount(resolveAuthUserId(user));
}
@Patch(":id/read")
@ApiOperation({ summary: "Mark one of my notifications as read" })
markRead(
@CurrentUser() user: AuthUserPayload,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.service.markRead(id, resolveAuthUserId(user));
}
@Post("read-all")
@ApiOperation({ summary: "Mark all my notifications as read" })
markAllRead(@CurrentUser() user: AuthUserPayload) {
return this.service.markAllRead(resolveAuthUserId(user));
}
// TODO: remove before merge — dev/verification helper only.
@Post("test")
@ApiOperation({
summary: "[dev] Send a test notification to the current user",
})
sendTest(
@CurrentUser() user: AuthUserPayload,
@Body()
body: {
audience?: NotificationAudience;
type?: NotificationType;
priority?: NotificationPriority;
title?: string;
message?: string;
},
) {
return this.service.sendTestToUser(resolveAuthUserId(user), body ?? {});
}
}

View File

@@ -0,0 +1,37 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { BackofficeModule } from "../backoffice/backoffice.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { Notification } from "./entities/notification.entity";
import { NotificationInboxController } from "./notification-inbox.controller";
import { NotificationInboxRepository } from "./notification-inbox.repository";
import { NotificationInboxService } from "./notification-inbox.service";
import { NotificationRecipientsService } from "./notification-recipients.service";
import { NotificationsGateway } from "./notifications.gateway";
import { WsAuthService } from "./ws-auth.service";
@Module({
imports: [
TypeOrmModule.forFeature([Notification, User, Session]),
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
CompaniesModule,
// BackofficeService.getOrganizationEmployees (staff targeting)
BackofficeModule,
// EmailClientService + SmsClientService (HIGH-priority fan-out)
NotificationsModule,
],
controllers: [NotificationInboxController],
providers: [
NotificationInboxRepository,
NotificationRecipientsService,
NotificationsGateway,
WsAuthService,
NotificationInboxService,
],
exports: [NotificationInboxService],
})
export class NotificationInboxModule {}

View File

@@ -0,0 +1,59 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { FindOptionsWhere, Repository } from "typeorm";
import { Notification } from "./entities/notification.entity";
@Injectable()
export class NotificationInboxRepository extends BaseRepository<Notification> {
constructor(
@InjectRepository(Notification)
repo: Repository<Notification>,
) {
super(repo);
}
/** Newest-first page of a recipient's notifications, optionally read-filtered. */
async findForRecipient(
userId: string,
opts: { page?: number; limit?: number; isRead?: boolean } = {},
): Promise<[Notification[], number]> {
const page = opts.page && opts.page > 0 ? opts.page : 1;
const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
const where: FindOptionsWhere<Notification> = { recipientUserId: userId };
if (typeof opts.isRead === "boolean") {
where.isRead = opts.isRead;
}
return this.repository.findAndCount({
where,
order: { createdAt: "DESC" },
skip: (page - 1) * limit,
take: limit,
});
}
async countUnread(userId: string): Promise<number> {
return this.repository.count({
where: { recipientUserId: userId, isRead: false },
});
}
/** Mark a single notification read (scoped to its recipient). Returns true if it changed. */
async markRead(id: string, userId: string): Promise<boolean> {
const result = await this.repository.update(
{ id, recipientUserId: userId, isRead: false },
{ isRead: true, readAt: new Date() },
);
return (result.affected ?? 0) > 0;
}
/** Mark all of a recipient's unread notifications read. Returns the count updated. */
async markAllRead(userId: string): Promise<number> {
const result = await this.repository.update(
{ recipientUserId: userId, isRead: false },
{ isRead: true, readAt: new Date() },
);
return result.affected ?? 0;
}
}

View File

@@ -0,0 +1,239 @@
import {
NotificationAudience,
NotificationChannels,
NotificationChannelsSent,
NotificationDto,
NotificationListResult,
NotificationPriority,
NotificationType,
NotifyInput,
} from "@edr/types";
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service";
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
import { Notification } from "./entities/notification.entity";
import { NotificationInboxRepository } from "./notification-inbox.repository";
import { NotificationRecipientsService } from "./notification-recipients.service";
import { NotificationsGateway } from "./notifications.gateway";
/**
* The single entry point subsystems use for in-app notifications. Call
* {@link notify}; everything else (reads, mark-read) backs the REST controller.
*
* `notify` is deliberately fault-tolerant: it never throws into the caller so a
* notification failure can't roll back or break the business transaction that
* triggered it. Failures are logged.
*/
@Injectable()
export class NotificationInboxService {
private readonly logger = new Logger(NotificationInboxService.name);
constructor(
private readonly repo: NotificationInboxRepository,
private readonly recipients: NotificationRecipientsService,
private readonly gateway: NotificationsGateway,
private readonly emailClient: EmailClientService,
private readonly smsClient: SmsClientService,
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
/**
* Fan a logical notification out to every resolved recipient: persist one row
* each, push it live over WebSocket, and (for HIGH priority) also queue
* email/SMS via the existing clients.
*/
async notify(input: NotifyInput): Promise<void> {
try {
const userIds = await this.recipients.resolve(input.recipients);
if (userIds.length === 0) {
this.logger.debug(
`notify(${input.type}) resolved 0 recipients — skipped`,
);
return;
}
const priority = input.priority ?? NotificationPriority.NORMAL;
for (const userId of userIds) {
await this.deliverToUser(userId, input, priority);
}
} catch (err) {
this.logger.error(
`notify failed: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
async list(
userId: string,
query: ListNotificationsQueryDto,
): Promise<NotificationListResult> {
const [items, count] = await this.repo.findForRecipient(userId, {
page: query.page,
limit: query.limit,
isRead: query.isRead,
});
const unreadCount = await this.repo.countUnread(userId);
return { items: items.map((n) => this.toDto(n)), count, unreadCount };
}
async unreadCount(userId: string): Promise<{ unreadCount: number }> {
return { unreadCount: await this.repo.countUnread(userId) };
}
async markRead(
id: string,
userId: string,
): Promise<{ success: boolean; unreadCount: number }> {
const success = await this.repo.markRead(id, userId);
const unreadCount = await this.repo.countUnread(userId);
this.gateway.emitUnreadCount(userId, unreadCount);
return { success, unreadCount };
}
async markAllRead(
userId: string,
): Promise<{ updated: number; unreadCount: number }> {
const updated = await this.repo.markAllRead(userId);
const unreadCount = await this.repo.countUnread(userId);
this.gateway.emitUnreadCount(userId, unreadCount);
return { updated, unreadCount };
}
/** [dev/verification only] Send a canned notification straight to one user. */
async sendTestToUser(
userId: string,
body: {
audience?: NotificationAudience;
type?: NotificationType;
priority?: NotificationPriority;
title?: string;
message?: string;
},
): Promise<NotificationDto> {
const entity = await this.repo.create({
recipientUserId: userId,
audience: body.audience ?? NotificationAudience.BACKOFFICE,
type: body.type ?? NotificationType.GENERIC,
title: body.title ?? "Test notification",
body: body.message ?? "This is a test in-app notification.",
priority: body.priority ?? NotificationPriority.NORMAL,
isRead: false,
});
const dto = this.toDto(entity);
this.gateway.emitNew(userId, dto, await this.repo.countUnread(userId));
return dto;
}
private async deliverToUser(
userId: string,
input: NotifyInput,
priority: NotificationPriority,
): Promise<void> {
const entity = await this.repo.create({
recipientUserId: userId,
audience: input.audience,
type: input.type,
title: input.title,
body: input.body,
link: input.link ?? null,
data: input.data ?? null,
priority,
isRead: false,
});
const unreadCount = await this.repo.countUnread(userId);
this.gateway.emitNew(userId, this.toDto(entity), unreadCount);
const channels = this.resolveChannels(input, priority);
if (channels.email || channels.sms) {
const channelsSent = await this.fanOut(userId, input, channels);
if (channelsSent) {
await this.repo.update(entity.id, { channelsSent });
}
}
}
/**
* Decide which outbound channels to use. An explicit `input.channels`
* selection wins; otherwise fall back to priority (HIGH ⇒ email + SMS).
*/
private resolveChannels(
input: NotifyInput,
priority: NotificationPriority,
): Required<NotificationChannels> {
if (input.channels) {
return {
email: input.channels.email === true,
sms: input.channels.sms === true,
};
}
const high = priority === NotificationPriority.HIGH;
return { email: high, sms: high };
}
/**
* Best-effort email/SMS fan-out for the requested channels. Skips a channel
* the recipient has no address for. Never throws.
*/
private async fanOut(
userId: string,
input: NotifyInput,
channels: Required<NotificationChannels>,
): Promise<NotificationChannelsSent | null> {
try {
const user = await this.users.findOne({
where: { id: userId } as never,
});
if (!user) return null;
const sent: NotificationChannelsSent = {};
const text = `${input.title}\n\n${input.body}`;
if (channels.email && user.email) {
const res = await this.emailClient.sendEmail({
to: user.email,
subject: input.title,
text,
});
sent.email = res.queued;
}
if (channels.sms && user.phoneNumber) {
const res = await this.smsClient.sendSms({
to: user.phoneNumber,
message: text,
});
sent.sms = res.queued;
}
return Object.keys(sent).length ? sent : null;
} catch (err) {
this.logger.warn(
`fan-out failed for user ${userId}: ${(err as Error).message}`,
);
return null;
}
}
private toDto(n: Notification): NotificationDto {
return {
id: n.id,
recipientUserId: n.recipientUserId,
audience: n.audience,
type: n.type,
title: n.title,
body: n.body,
link: n.link ?? null,
data: n.data ?? null,
priority: n.priority,
isRead: n.isRead,
readAt: n.readAt ? new Date(n.readAt).toISOString() : null,
createdAt: new Date(n.createdAt).toISOString(),
};
}
}

View File

@@ -0,0 +1,87 @@
import { NotificationRecipients } from "@edr/types";
import { Injectable, Logger } from "@nestjs/common";
import { BackofficeService } from "../backoffice/backoffice.service";
import { CompanyProfileRepository } from "../companies/company-profile.repository";
import { ExternalProfileRepository } from "../companies/external-profile.repository";
/**
* Turns a {@link NotificationRecipients} selector into a de-duplicated set of
* IAM user ids.
*
* - `userIds` → honored as-is.
* - `companyId` → all portal users linked to the company (external_profiles).
* - `companyProfileId` → resolved to its company, then to that company's users.
* - `organizationId` → all current employees of the org (backoffice staff).
*
* NOTE: permission-scoped staff targeting is intentionally unsupported — freight
* has no "users-by-permission" lookup. Target explicit userIds or an org instead.
*/
@Injectable()
export class NotificationRecipientsService {
private readonly logger = new Logger(NotificationRecipientsService.name);
constructor(
private readonly externalProfiles: ExternalProfileRepository,
private readonly companyProfiles: CompanyProfileRepository,
private readonly backoffice: BackofficeService,
) {}
async resolve(recipients: NotificationRecipients): Promise<string[]> {
const ids = new Set<string>();
for (const id of recipients.userIds ?? []) {
if (id) ids.add(id);
}
let companyId = recipients.companyId;
if (!companyId && recipients.companyProfileId) {
const profile = await this.companyProfiles.findById(
recipients.companyProfileId,
);
companyId = profile?.companyId ?? undefined;
}
if (companyId) {
const profiles = await this.externalProfiles.findByCompanyId(companyId);
for (const p of profiles) {
if (p.userId) ids.add(p.userId);
}
}
if (recipients.organizationId) {
try {
const { items } = await this.backoffice.getOrganizationEmployees(
recipients.organizationId,
{},
);
for (const employee of items as Array<{
user?: { id?: string };
userId?: string;
}>) {
const uid = employee?.user?.id ?? employee?.userId;
if (uid) ids.add(uid);
}
} catch (err) {
this.logger.warn(
`Failed to resolve org recipients for ${recipients.organizationId}: ${
(err as Error).message
}`,
);
}
}
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];
}
}

View File

@@ -0,0 +1,75 @@
import {
NOTIFICATION_WS_EVENTS,
NOTIFICATION_WS_NAMESPACE,
NotificationDto,
} from "@edr/types";
import { Logger } from "@nestjs/common";
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
import { WsAuthService } from "./ws-auth.service";
/**
* Server → client push for in-app notifications. Clients only *listen* (no
* `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
* the handshake is authenticated in `handleConnection` and each socket joins a
* private `user:<id>` room the service targets.
*/
@WebSocketGateway({
namespace: NOTIFICATION_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class NotificationsGateway implements OnGatewayConnection {
private readonly logger = new Logger(NotificationsGateway.name);
@WebSocketServer()
private readonly server!: Server;
constructor(private readonly wsAuth: WsAuthService) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
if (!userId) {
this.logger.debug(`Rejected notifications handshake ${socket.id}`);
socket.disconnect(true);
return;
}
socket.data.userId = userId;
await socket.join(this.room(userId));
}
/** Push a freshly-created notification + the new unread count to a user. */
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
const room = this.server.to(this.room(userId));
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
}
/** Push only an updated unread count (e.g. after a read on another tab). */
emitUnreadCount(userId: string, unreadCount: number): void {
this.server
.to(this.room(userId))
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
}
private room(userId: string): string {
return `user:${userId}`;
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === "string") return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith("Bearer ")) return header.slice(7);
return undefined;
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { verifyToken } from "@tria-plc/api-common/utils/token";
import { ESessionStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
/**
* Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access
* token payload is only a *session* pointer (`{ id: <sessionId> }`), not the
* user — so we verify the signature (`verifyToken`), then load the IAM session
* and require it to be ACTIVE and unexpired, and read the real user id out of
* `session.userInfo`. There is no context-free verifier in the auth package, so
* this lookup is unavoidable; using the typed `Session` entity (rather than raw
* SQL) keeps it column-rename-safe and consistent with the package's own model.
*
* Returns the IAM user id, or null for any invalid/expired/revoked/malformed token.
*/
@Injectable()
export class WsAuthService {
private readonly logger = new Logger(WsAuthService.name);
constructor(
@InjectRepository(Session)
private readonly sessions: Repository<Session>,
) {}
async resolveUserId(token?: string): Promise<string | null> {
if (!token) return null;
try {
const payload = verifyToken(token) as { id?: string };
const sessionId = payload?.id;
if (!sessionId) return null;
const session = await this.sessions.findOne({
where: { id: sessionId },
});
if (!session) return null;
if (session.status !== ESessionStatus.ACTIVE) return null;
if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) {
return null;
}
return session.userInfo?.id ?? null;
} catch (err) {
this.logger.debug(`WS auth rejected: ${(err as Error).message}`);
return null;
}
}
}

View File

@@ -0,0 +1,193 @@
import {
IsUUID,
IsString,
IsDateString,
IsNumber,
IsInt,
IsOptional,
IsEnum,
IsBoolean,
} from 'class-validator';
import { VendorType } from '../entities/vendor.entity';
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
import { DisposalMethod } from '../entities/asset-disposal.entity';
export class CreateVendorDto {
@IsString()
name!: string;
@IsOptional()
@IsEnum(VendorType)
type?: VendorType;
@IsOptional()
@IsString()
contactPerson?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateVendorDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsEnum(VendorType)
type?: VendorType;
@IsOptional()
@IsString()
contactPerson?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateAcquisitionDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
vendorId?: string;
@IsEnum(AcquisitionType)
acquisitionType!: AcquisitionType;
@IsDateString()
acquisitionDate!: string;
@IsOptional()
@IsNumber()
cost?: number;
@IsOptional()
@IsInt()
usefulLifeMonths?: number;
@IsOptional()
@IsNumber()
salvageValue?: number;
@IsOptional()
@IsDateString()
leaseStart?: string;
@IsOptional()
@IsDateString()
leaseEnd?: string;
@IsOptional()
@IsNumber()
monthlyPayment?: number;
@IsOptional()
@IsEnum(AcquisitionStatus)
status?: AcquisitionStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateAcquisitionDto {
@IsOptional()
@IsUUID()
vehicleId?: string;
@IsOptional()
@IsUUID()
vendorId?: string;
@IsOptional()
@IsEnum(AcquisitionType)
acquisitionType?: AcquisitionType;
@IsOptional()
@IsDateString()
acquisitionDate?: string;
@IsOptional()
@IsNumber()
cost?: number;
@IsOptional()
@IsInt()
usefulLifeMonths?: number;
@IsOptional()
@IsNumber()
salvageValue?: number;
@IsOptional()
@IsDateString()
leaseStart?: string;
@IsOptional()
@IsDateString()
leaseEnd?: string;
@IsOptional()
@IsNumber()
monthlyPayment?: number;
@IsOptional()
@IsEnum(AcquisitionStatus)
status?: AcquisitionStatus;
@IsOptional()
@IsString()
notes?: string;
}
export class CreateDisposalDto {
@IsUUID()
vehicleId!: string;
@IsDateString()
disposalDate!: string;
@IsEnum(DisposalMethod)
method!: DisposalMethod;
@IsOptional()
@IsNumber()
salePrice?: number;
@IsOptional()
@IsString()
buyer?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,64 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { Vendor } from './vendor.entity';
export enum AcquisitionType {
PURCHASE = 'PURCHASE',
LEASE = 'LEASE',
RENTAL = 'RENTAL',
}
export enum AcquisitionStatus {
ACTIVE = 'ACTIVE',
LEASE_EXPIRING = 'LEASE_EXPIRING',
DISPOSED = 'DISPOSED',
}
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
@Index(['vehicleId', 'acquisitionDate'])
export class AssetAcquisition extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;
@ManyToOne(() => Vehicle, { eager: false, nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
@Column({ name: 'vendor_id', type: 'uuid', nullable: true })
vendorId?: string;
@ManyToOne(() => Vendor, { eager: false, nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'vendor_id' })
vendor?: Vendor;
@Column({ name: 'acquisition_type', type: 'varchar' })
acquisitionType!: AcquisitionType;
@Column({ name: 'acquisition_date', type: 'date' })
acquisitionDate!: string;
@Column({ name: 'cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
cost?: number;
@Column({ name: 'useful_life_months', type: 'int', nullable: true })
usefulLifeMonths?: number;
@Column({ name: 'salvage_value', type: 'numeric', precision: 14, scale: 2, nullable: true })
salvageValue?: number;
@Column({ name: 'lease_start', type: 'date', nullable: true })
leaseStart?: string;
@Column({ name: 'lease_end', type: 'date', nullable: true })
leaseEnd?: string;
@Column({ name: 'monthly_payment', type: 'numeric', precision: 14, scale: 2, nullable: true })
monthlyPayment?: number;
@Column({ name: 'status', type: 'varchar', default: AcquisitionStatus.ACTIVE })
status!: AcquisitionStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,31 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, Index } from 'typeorm';
export enum DisposalMethod {
SALE = 'SALE',
SCRAP = 'SCRAP',
RETURN_LEASE = 'RETURN_LEASE',
TRADE_IN = 'TRADE_IN',
}
@Entity({ name: 'asset_disposals', schema: 'freight' })
@Index(['vehicleId', 'disposalDate'])
export class AssetDisposal extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@Column({ name: 'disposal_date', type: 'date' })
disposalDate!: string;
@Column({ name: 'method', type: 'varchar' })
method!: DisposalMethod;
@Column({ name: 'sale_price', type: 'numeric', precision: 14, scale: 2, nullable: true })
salePrice?: number;
@Column({ name: 'buyer', nullable: true })
buyer?: string;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column } from 'typeorm';
export enum VendorType {
DEALER = 'DEALER',
LEASING = 'LEASING',
PARTS = 'PARTS',
SERVICE = 'SERVICE',
OTHER = 'OTHER',
}
@Entity({ name: 'vendors', schema: 'freight' })
export class Vendor extends BaseEntity {
@Column({ name: 'name' })
name!: string;
@Column({ name: 'type', type: 'varchar', nullable: true })
type?: VendorType;
@Column({ name: 'contact_person', nullable: true })
contactPerson?: string;
@Column({ name: 'phone', nullable: true })
phone?: string;
@Column({ name: 'email', nullable: true })
email?: string;
@Column({ name: 'address', nullable: true })
address?: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,98 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ProcurementService } from './procurement.service';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
@ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement')
export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {}
// ---- Vendors ----
@Post('vendors')
@ApiOperation({ summary: 'Create a vendor' })
async createVendor(@Body() dto: CreateVendorDto) {
return this.procurementService.createVendor(dto);
}
@Get('vendors')
@ApiOperation({ summary: 'List vendors' })
async listVendors() {
return this.procurementService.listVendors();
}
@Patch('vendors/:id')
@ApiOperation({ summary: 'Update a vendor' })
async updateVendor(@Param('id') id: string, @Body() dto: UpdateVendorDto) {
return this.procurementService.updateVendor(id, dto);
}
@Delete('vendors/:id')
@ApiOperation({ summary: 'Delete a vendor' })
async deleteVendor(@Param('id') id: string) {
return this.procurementService.deleteVendor(id);
}
// ---- Acquisitions ----
@Post('acquisitions')
@ApiOperation({ summary: 'Create an asset acquisition' })
async createAcquisition(@Body() dto: CreateAcquisitionDto) {
return this.procurementService.createAcquisition(dto);
}
@Get('acquisitions')
@ApiOperation({ summary: 'List asset acquisitions (optionally filtered by vehicleId)' })
async listAcquisitions(@Query('vehicleId') vehicleId?: string) {
return this.procurementService.listAcquisitions(vehicleId);
}
@Get('acquisitions/:id')
@ApiOperation({ summary: 'Get an asset acquisition by id' })
async getAcquisition(@Param('id') id: string) {
return this.procurementService.getAcquisition(id);
}
@Patch('acquisitions/:id')
@ApiOperation({ summary: 'Update an asset acquisition' })
async updateAcquisition(@Param('id') id: string, @Body() dto: UpdateAcquisitionDto) {
return this.procurementService.updateAcquisition(id, dto);
}
@Delete('acquisitions/:id')
@ApiOperation({ summary: 'Delete an asset acquisition' })
async deleteAcquisition(@Param('id') id: string) {
return this.procurementService.deleteAcquisition(id);
}
// ---- Disposals ----
@Post('disposals')
@ApiOperation({ summary: 'Create an asset disposal' })
async createDisposal(@Body() dto: CreateDisposalDto) {
return this.procurementService.createDisposal(dto);
}
@Get('disposals')
@ApiOperation({ summary: 'List asset disposals' })
async listDisposals() {
return this.procurementService.listDisposals();
}
@Delete('disposals/:id')
@ApiOperation({ summary: 'Delete an asset disposal' })
async deleteDisposal(@Param('id') id: string) {
return this.procurementService.deleteDisposal(id);
}
// ---- Lifecycle ----
@Get('lifecycle/:vehicleId')
@ApiOperation({ summary: 'Get asset lifecycle (acquisition, disposal, depreciation) for a vehicle' })
async lifecycle(@Param('vehicleId') vehicleId: string) {
return this.procurementService.lifecycle(vehicleId);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import { ProcurementService } from './procurement.service';
import { ProcurementRepository } from './procurement.repository';
import { ProcurementController } from './procurement.controller';
@Module({
imports: [TypeOrmModule.forFeature([Vendor, AssetAcquisition, AssetDisposal])],
providers: [ProcurementService, ProcurementRepository],
controllers: [ProcurementController],
exports: [ProcurementService],
})
export class ProcurementModule {}

View File

@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { DeepPartial, Repository } from 'typeorm';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
@Injectable()
export class ProcurementRepository extends BaseRepository<AssetAcquisition> {
constructor(
@InjectRepository(AssetAcquisition)
private readonly acquisitionRepository: Repository<AssetAcquisition>,
@InjectRepository(Vendor)
private readonly vendorRepository: Repository<Vendor>,
@InjectRepository(AssetDisposal)
private readonly disposalRepository: Repository<AssetDisposal>,
) {
super(acquisitionRepository);
}
// ---- Vendors ----
async createVendor(data: DeepPartial<Vendor>): Promise<Vendor> {
const vendor = this.vendorRepository.create(data);
return this.vendorRepository.save(vendor);
}
async findVendors(): Promise<Vendor[]> {
return this.vendorRepository.find({ order: { createdAt: 'DESC' } });
}
async updateVendor(id: string, data: DeepPartial<Vendor>): Promise<Vendor | null> {
await this.vendorRepository.update(id, data as never);
return this.vendorRepository.findOneBy({ id });
}
async softDeleteVendor(id: string): Promise<void> {
await this.vendorRepository.softDelete(id);
}
// ---- Acquisitions ----
async createAcquisition(data: DeepPartial<AssetAcquisition>): Promise<AssetAcquisition> {
const acquisition = this.acquisitionRepository.create(data);
return this.acquisitionRepository.save(acquisition);
}
async findAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.acquisitionRepository.find({
where: vehicleId ? { vehicleId } : {},
relations: ['vehicle', 'vendor'],
order: { acquisitionDate: 'DESC' },
});
}
async findAcquisitionById(id: string): Promise<AssetAcquisition | null> {
return this.acquisitionRepository.findOne({
where: { id },
relations: ['vehicle', 'vendor'],
});
}
async updateAcquisition(
id: string,
data: DeepPartial<AssetAcquisition>,
): Promise<AssetAcquisition | null> {
await this.acquisitionRepository.update(id, data as never);
return this.findAcquisitionById(id);
}
async softDeleteAcquisition(id: string): Promise<void> {
await this.acquisitionRepository.softDelete(id);
}
async findLatestAcquisitionByVehicle(vehicleId: string): Promise<AssetAcquisition | null> {
return this.acquisitionRepository.findOne({
where: { vehicleId },
relations: ['vehicle', 'vendor'],
order: { acquisitionDate: 'DESC' },
});
}
// ---- Disposals ----
async createDisposal(data: DeepPartial<AssetDisposal>): Promise<AssetDisposal> {
const disposal = this.disposalRepository.create(data);
return this.disposalRepository.save(disposal);
}
async findDisposals(): Promise<AssetDisposal[]> {
return this.disposalRepository.find({ order: { disposalDate: 'DESC' } });
}
async softDeleteDisposal(id: string): Promise<void> {
await this.disposalRepository.softDelete(id);
}
async findLatestDisposalByVehicle(vehicleId: string): Promise<AssetDisposal | null> {
return this.disposalRepository.findOne({
where: { vehicleId },
order: { disposalDate: 'DESC' },
});
}
}

View File

@@ -0,0 +1,143 @@
import { Injectable } from '@nestjs/common';
import { ProcurementRepository } from './procurement.repository';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import {
CreateVendorDto,
UpdateVendorDto,
CreateAcquisitionDto,
UpdateAcquisitionDto,
CreateDisposalDto,
} from './dto/procurement.dto';
export interface DepreciationResult {
method: 'STRAIGHT_LINE';
cost: number;
salvageValue: number;
usefulLifeMonths: number;
monthsElapsed: number;
monthlyDepreciation: number;
bookValue: number;
}
export interface LifecycleResult {
vehicleId: string;
acquisition: AssetAcquisition | null;
disposal: AssetDisposal | null;
depreciation: DepreciationResult | null;
}
@Injectable()
export class ProcurementService {
constructor(private readonly procurementRepository: ProcurementRepository) {}
// ---- Vendors ----
async createVendor(dto: CreateVendorDto): Promise<Vendor> {
return this.procurementRepository.createVendor(dto);
}
async listVendors(): Promise<Vendor[]> {
return this.procurementRepository.findVendors();
}
async updateVendor(id: string, dto: UpdateVendorDto): Promise<Vendor | null> {
return this.procurementRepository.updateVendor(id, dto);
}
async deleteVendor(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteVendor(id);
return { success: true };
}
// ---- Acquisitions ----
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
return this.procurementRepository.createAcquisition(dto);
}
async listAcquisitions(vehicleId?: string): Promise<AssetAcquisition[]> {
return this.procurementRepository.findAcquisitions(vehicleId);
}
async getAcquisition(id: string): Promise<AssetAcquisition | null> {
return this.procurementRepository.findAcquisitionById(id);
}
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
return this.procurementRepository.updateAcquisition(id, dto);
}
async deleteAcquisition(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteAcquisition(id);
return { success: true };
}
// ---- Disposals ----
async createDisposal(dto: CreateDisposalDto): Promise<AssetDisposal> {
return this.procurementRepository.createDisposal(dto);
}
async listDisposals(): Promise<AssetDisposal[]> {
return this.procurementRepository.findDisposals();
}
async deleteDisposal(id: string): Promise<{ success: boolean }> {
await this.procurementRepository.softDeleteDisposal(id);
return { success: true };
}
// ---- Lifecycle ----
async lifecycle(vehicleId: string): Promise<LifecycleResult> {
const acquisition = await this.procurementRepository.findLatestAcquisitionByVehicle(vehicleId);
const disposal = await this.procurementRepository.findLatestDisposalByVehicle(vehicleId);
return {
vehicleId,
acquisition,
disposal,
depreciation: this.computeStraightLineDepreciation(acquisition),
};
}
/**
* Straight-line depreciation. Requires a cost and a positive useful life.
* monthlyDep = (cost - salvageValue) / usefulLifeMonths
* bookValue = cost - monthlyDep * monthsElapsedSinceAcquisition, floored at salvageValue.
*/
private computeStraightLineDepreciation(
acquisition: AssetAcquisition | null,
): DepreciationResult | null {
if (!acquisition) return null;
const cost = acquisition.cost != null ? Number(acquisition.cost) : null;
const usefulLifeMonths =
acquisition.usefulLifeMonths != null ? Number(acquisition.usefulLifeMonths) : null;
if (cost == null || usefulLifeMonths == null || usefulLifeMonths <= 0) {
return null;
}
const salvageValue = acquisition.salvageValue != null ? Number(acquisition.salvageValue) : 0;
const monthlyDepreciation = (cost - salvageValue) / usefulLifeMonths;
const acquiredAt = new Date(acquisition.acquisitionDate);
const now = new Date();
const monthsElapsed = Math.max(
0,
(now.getFullYear() - acquiredAt.getFullYear()) * 12 +
(now.getMonth() - acquiredAt.getMonth()),
);
const bookValue = Math.max(cost - monthlyDepreciation * monthsElapsed, salvageValue);
return {
method: 'STRAIGHT_LINE',
cost,
salvageValue,
usefulLifeMonths,
monthsElapsed,
monthlyDepreciation,
bookValue,
};
}
}

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import type { ScheduleTradeDirection } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
@@ -26,6 +27,14 @@ export class Route extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
status!: RouteStatus;
/**
* Trade direction frozen from the yard countries at create/update
* (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity").
* Consumers (scheduling, booking windows) read this instead of re-deriving.
*/
@Column({ name: 'direction', type: 'varchar', length: 10 })
direction!: ScheduleTradeDirection;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[];
}

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -83,6 +84,7 @@ export class RoutesService {
originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId,
status: dto.status ?? 'AVAILABLE',
direction: validated.direction,
}),
);
@@ -115,6 +117,7 @@ export class RoutesService {
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId:
milestoneInput?.destinationYardId ?? existing.destinationYardId,
...(milestoneInput ? { direction: milestoneInput.direction } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
});
@@ -187,9 +190,18 @@ export class RoutesService {
throw new BadRequestException('Origin and destination yards must be different');
}
const originYardId = normalized[0].yardId;
const destinationYardId = normalized[normalized.length - 1].yardId;
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
const direction = deriveTradeDirection(
yardById.get(originYardId) ?? { country: null },
yardById.get(destinationYardId) ?? { country: null },
);
return {
originYardId: normalized[0].yardId,
destinationYardId: normalized[normalized.length - 1].yardId,
originYardId,
destinationYardId,
direction,
milestones: normalized,
};
}

View File

@@ -1,5 +1,6 @@
import { YardCountry } from '@edr/types';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator';
export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@@ -7,10 +8,9 @@ export class CreateYardDto {
@MaxLength(100)
label!: string;
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 })
@IsString()
@MaxLength(50)
country!: string;
@ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' })
@IsEnum(YardCountry)
country!: YardCountry;
@ApiPropertyOptional({ default: true })
@IsOptional()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'yards' })
@@ -12,8 +13,11 @@ export class Yard extends BaseEntity {
@Column({ name: 'label', type: 'varchar', length: 100 })
label!: string;
// Constrained to YardCountry by DTO validation + a DB CHECK constraint;
// route/schedule trade direction is derived from this value. Typed as the
// enum's literal values so plain strings from seeds/queries still fit.
@Column({ name: 'country', type: 'varchar', length: 50 })
country!: string;
country!: `${YardCountry}`;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => {
bookingsRepository as never,
trainSchedulingService as never,
schedulingRescheduleRepository as never,
{
rescheduled: jest.fn(),
removedFromTrain: jest.fn(),
maintenanceMoved: jest.fn(),
} as never, // notifier
);
});

View File

@@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
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 { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
@@ -38,6 +39,7 @@ export class SchedulingRescheduleService {
private readonly bookingsRepository: BookingsRepository,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
private readonly notifier: BookingNotifierService,
) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */
@@ -193,9 +195,64 @@ export class SchedulingRescheduleService {
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 };
}
/**
* 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. */
async maintenanceReschedule(
scheduleId: string,

View File

@@ -302,3 +302,41 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(withEarly?.window?.label).toContain('08:00');
});
});
// Regression: a schedule created INSIDE its own window day must open right away
// when the desk is open, and re-deriving after a settings change (close hour
// extended past "now", or lead pulled so the window day becomes today) must
// yield an immediate open — not tomorrow morning.
describe('computeImportWindowTimes — immediate open inside the window day', () => {
// 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC
const now = new Date('2026-07-06T16:15:17.000Z');
// Departs Thu 9 Jul ~08:53 EAT
const departure = new Date('2026-07-09T05:53:00.000Z');
const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 };
it('desk 823, created 19:15 on the window day → opens NOW', () => {
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
it('desk 817, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => {
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now);
expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z');
});
it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => {
// Same call restampPendingWindows makes after the global-rules edit.
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => {
const departsJul10 = new Date('2026-07-10T05:53:00.000Z');
const t = computeImportWindowTimes(
departsJul10,
{ ...base, importWindowLeadDays: 4, windowCloseHour: 23 },
now,
);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
});

View File

@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
/**
* Export booking window: a single FCFS window from `exportBookingLeadHours`
* before departure until departure. The open honours the daily desk hours —
* when the raw lead instant lands while the desk is shut, the window opens at
* the next desk opening instead (capped at departure, so a config whose desk
* never opens before the train leaves yields a zero-length window rather than
* one that outlives the train).
*/
export function computeExportWindowTimes(
departure: Date,
cfg: { exportBookingLeadHours: number },
cfg: {
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
},
): InitialWindowTimes {
return {
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
windowClosesAt: departure,
};
const rawOpen = new Date(
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
);
let opensAt = officeHoursOpen(rawOpen, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (opensAt.getTime() > departure.getTime()) {
opensAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: departure };
}
/**
@@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
*
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
): BoardWindow[] {
if (direction === 'EXPORT') {
const start =
anchorOpensAt ??
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
return [boardWindowFromInterval(start, departure)];
}

View File

@@ -122,6 +122,7 @@ describe('BookingBatchService — PAID reconcile', () => {
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
);
});

View File

@@ -40,10 +40,11 @@ import {
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
export interface Capacity {
wagons: number;
weightTons: number;
lengthMeters: number;
@@ -204,6 +205,7 @@ export class BookingBatchService implements OnModuleInit {
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService,
private readonly bookingWindowGateway: BookingWindowGateway,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
@@ -380,6 +382,18 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
} else {
// Already linked at booking time (export FCFS: the customer books a
// specific train, so allocate() ran up front). allocate() is where the
// payment-settled tracking milestones are written, so on this branch we
// record them here — otherwise a paid, already-linked booking leaves
// FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks.
void this.completeTrackingMilestones(bookingId, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
void this.markWagonAllocatedMilestone(bookingId);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
@@ -1444,6 +1458,48 @@ export class BookingBatchService implements OnModuleInit {
await this.fillSchedule(booking.trainScheduleId);
}
// ---- intercity ride-along API ---------------------------------------------
/**
* Remaining capacity budget (wagons / weight / length) for a schedule, and
* the per-booking need calculator — exposed for the intercity accept flow,
* which reserves ride-along bookings onto import/export trains outside the
* batch engine.
*/
async intercityCapacity(scheduleId: string): Promise<{
budget: Capacity;
needFor: (booking: Booking) => Capacity;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
}
/**
* Accept an intercity booking onto the given train. Commercial bookings get
* the same pay-window lifecycle as a batch reservation (deadline, invoice
* due-date sync, pay-now notify, settle on the window tick), so payment →
* allocation needs no special path. Government bookings allocate directly.
*/
async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
if (booking.isGovernment) {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'gov');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
}
// ---- mutations ------------------------------------------------------------
/**
@@ -1473,6 +1529,12 @@ export class BookingBatchService implements OnModuleInit {
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
// Customer tracking: a wagon slot is reserved and the freight pay window is
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
void this.completeTrackingMilestones(booking.id, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
]);
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
@@ -1504,6 +1566,15 @@ export class BookingBatchService implements OnModuleInit {
this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).
// Government allocations don't pay upfront — theirs stay pending.
if (reason === 'paid') {
void this.completeTrackingMilestones(booking.id, [
'WAGON_REQUESTED',
'FREIGHT_PAYMENT_PENDING',
'FREIGHT_PAYMENT_SETTLED',
]);
}
}
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
@@ -1515,6 +1586,27 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* Complete customer-tracking milestones on lifecycle events via the
* doc-trigger path — a silent no-op for bookings without milestone rows
* (non-customs bookings). Never blocks the batch action.
*/
private async completeTrackingMilestones(
bookingId: string,
codes: string[],
): Promise<void> {
if (!this.milestoneService) return;
for (const code of codes) {
try {
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
} catch (err) {
this.logger.warn(
`Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
);
}
}
}
/**
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train
@@ -1831,6 +1923,17 @@ export class BookingBatchService implements OnModuleInit {
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
// Push the change (open / train full / closed) so portal home and GL cards
// flip in real time — FULL in particular happens outside the window tick
// (batch fill, staff mark-paid) and had no live signal before.
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
/** No wagon slots left for allocated + reserved bookings. */

View File

@@ -1,13 +1,23 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@Injectable()
export class BookingNotifierService {
private readonly logger = new Logger(BookingNotifierService.name);
constructor(private readonly notifications: NotificationsService) {}
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
@@ -41,11 +51,34 @@ export class BookingNotifierService {
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title,
body,
link: `/bookings/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
async payNow(b: Booking, deadline: Date): Promise<void> {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW');
this.inApp(b, 'Payment window open', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/**
@@ -66,6 +99,9 @@ export class BookingNotifierService {
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
this.inApp(b, 'Partial allocation offer', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
secured(b: Booking, reason: 'paid' | 'gov'): void {
@@ -73,11 +109,13 @@ export class BookingNotifierService {
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);
}
expired(b: Booking): void {
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
void this.notifyContact(b, msg, 'EXPIRED');
this.inApp(b, 'Payment window expired', msg);
}
scheduleFull(b: Booking): void {
@@ -100,5 +138,42 @@ export class BookingNotifierService {
displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');
this.inApp(b, 'Booking displaced', msg);
}
/**
* Staff rescheduled the train carrying this booking to a new departure date.
* The booking stays on the train — only the date moved.
*/
rescheduled(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
void this.notifyContact(b, msg, 'RESCHEDULED');
this.inApp(b, 'Booking rescheduled', msg);
}
/**
* Booking was removed from its train during a staff reschedule (not a government
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
*/
removedFromTrain(b: Booking): void {
const msg =
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
`Please rebook or select a new schedule from the portal.`;
void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
this.inApp(b, 'Removed from train', msg);
}
/**
* The train carrying this booking was moved for maintenance to a new departure
* date. The booking stays on the train — only the date moved.
*/
maintenanceMoved(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg =
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
`New departure date: ${when}.`;
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
this.inApp(b, 'Train maintenance reschedule', msg);
}
}

View File

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

View File

@@ -0,0 +1,80 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from '@edr/types';
import { Logger } from '@nestjs/common';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* Server → client push for booking-window state changes. Same handshake model
* as the notifications gateway: clients only listen, the token is verified on
* connect. Events are broadcast namespace-wide — window state is route-scoped
* public information for signed-in users, and clients filter/invalidate their
* own queries.
*/
@WebSocketGateway({
namespace: BOOKING_WINDOW_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class BookingWindowGateway implements OnGatewayConnection {
private readonly logger = new Logger(BookingWindowGateway.name);
@WebSocketServer()
private readonly server!: Server;
constructor(private readonly wsAuth: WsAuthService) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
if (!userId) {
this.logger.debug(`Rejected booking-window handshake ${socket.id}`);
socket.disconnect(true);
return;
}
socket.data.userId = userId;
// Log at info so "is anyone actually connected?" is answerable from the
// API log when diagnosing missing live updates.
this.logger.log(`Booking-window client connected (user ${userId})`);
}
/** Push a schedule's current window state to every connected client. */
emitPhase(schedule: TrainSchedule): void {
const payload: BookingWindowPhaseEvent = {
scheduleId: schedule.id,
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
direction: schedule.direction ?? null,
phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'],
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
bookingCycleNo: schedule.bookingCycleNo,
windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null,
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === 'string') return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith('Bearer ')) return header.slice(7);
return undefined;
}
}

View File

@@ -2,13 +2,19 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com
import { Cron } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import {
NotificationAudience,
NotificationType,
TrainScheduleStatus as TrainScheduleStatusEnum,
} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
@@ -40,6 +46,8 @@ export class BookingWindowService implements OnModuleInit {
private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly gateway: BookingWindowGateway,
) {}
async onModuleInit(): Promise<void> {
@@ -48,7 +56,10 @@ export class BookingWindowService implements OnModuleInit {
);
}
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
// 10-second cadence: every transition is derived from persisted timestamps
// and applied idempotently, so a finer tick only shrinks the lag between a
// deadline passing and the phase actually moving (was a full minute).
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
async tick(): Promise<void> {
if (this.ticking) return;
this.ticking = true;
@@ -89,9 +100,10 @@ export class BookingWindowService implements OnModuleInit {
await this.settleOverdueReservations();
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick.
// Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
// (30 ticks at the 10-second cadence).
this.tickCount += 1;
if (this.tickCount % 5 === 0) {
if (this.tickCount % 30 === 0) {
await this.bookingBatchService.runBatchFill();
}
} finally {
@@ -151,6 +163,9 @@ export class BookingWindowService implements OnModuleInit {
? await this.advanceExport(schedule, now)
: await this.advanceImport(schedule, cfg, now);
if (!advanced) return;
// Push the new window state to portal home / backoffice GL sections so
// they refresh instantly instead of waiting out their poll interval.
this.gateway.emitPhase(schedule);
}
}
@@ -169,7 +184,9 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
await this.notifyWindowOpened(schedule);
// Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
// (the `ticking` guard would otherwise delay every schedule's transition).
void this.notifyWindowOpened(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true;
}
@@ -216,7 +233,8 @@ export class BookingWindowService implements OnModuleInit {
schedule.bookingWindowStatus = 'OPEN';
}
// Only announce the first opening of the day; reopen cycles don't re-notify.
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
);
@@ -368,22 +386,26 @@ export class BookingWindowService implements OnModuleInit {
*/
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
try {
const rows: Array<{ phone: string | null; email: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT
COALESCE(co.contact_person_phone, co.phone) AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
const rows: Array<{
company_id: string;
phone: string | null;
email: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT
c.company_id,
COALESCE(co.contact_person_phone, co.phone) AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
if (!rows.length) return;
const closes = schedule.windowClosesAt
@@ -398,6 +420,7 @@ export class BookingWindowService implements OnModuleInit {
const seenPhone = new Set<string>();
const seenEmail = new Set<string>();
const seenCompany = new Set<string>();
for (const r of rows) {
if (r.phone && !seenPhone.has(r.phone)) {
seenPhone.add(r.phone);
@@ -411,9 +434,23 @@ export class BookingWindowService implements OnModuleInit {
.directSend('email', r.email, msg)
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
}
// In-app inbox item for every portal user of each eligible company,
// deep-linking to the new-booking page.
if (r.company_id && !seenCompany.has(r.company_id)) {
seenCompany.add(r.company_id);
void this.inbox.notify({
recipients: { companyId: r.company_id },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Booking window open',
body: msg,
link: '/bookings/new',
data: { trainScheduleId: schedule.id },
});
}
}
this.logger.log(
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
`Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`,
);
} catch (err) {
this.logger.warn(

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class AcceptIntercityBookingsDto {
@ApiProperty({
type: [String],
description:
'Waiting intercity booking ids to accept onto this train, in priority order',
})
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
bookingIds!: string[];
}

View File

@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
@IsInt()
@Min(0)
importWindowLeadDays?: number;
@ApiPropertyOptional({
example: 24,
description:
'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
exportBookingLeadHours?: number;
}

Some files were not shown because too many files have changed in this diff Show More