mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 16:13:40 +00:00
enhance booking and contract notification systems
- Added detailed logging for socket connection events in useBookingWindowSocket. - Introduced new notification types for contract status and schedule updates. - Updated notification visuals to include new icons for contract status. - Enhanced notification href resolution for contract status and schedule updates. - Implemented booking lifecycle notifier service for customer and staff notifications. - Created contract notifier service for managing contract lifecycle notifications. - Added end-to-end tests for booking window socket functionality.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
@@ -414,6 +416,7 @@ export class BookingClearanceService {
|
||||
},
|
||||
userId,
|
||||
);
|
||||
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
@@ -441,6 +444,7 @@ export class BookingClearanceService {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
|
||||
this.notifier.dutySlipUploadedToStaff(booking, 'first');
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
NotifyInput,
|
||||
} from '@edr/types';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||
* event fans out over three channels: SMS + email (direct, via
|
||||
* {@link NotificationsService}) and a persisted in-app notification (via
|
||||
* {@link NotificationInboxService}) that deep-links to the contract detail page.
|
||||
* Staff events go to the backoffice inbox. All sends are fire-and-forget and
|
||||
* never throw — a notification failure must not break a contract transition.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractNotifierService {
|
||||
private readonly logger = new Logger(ContractNotifierService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
private ref(c: Contract): string {
|
||||
return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
|
||||
/** Send SMS + email to the contract's company contact; log-only on failure. */
|
||||
private async notifyContact(
|
||||
c: Contract,
|
||||
message: string,
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
|
||||
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the contract's company. */
|
||||
private inApp(
|
||||
c: Contract,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (!c.companyId) return; // government/unlinked contracts have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: c.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.CONTRACT_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: `/contracts/${c.id}`,
|
||||
data: { contractId: c.id, reference: c.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to every backoffice staff user. */
|
||||
private inAppStaff(
|
||||
c: Contract,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
body,
|
||||
link: `/dashboard/contract-requests/${c.id}`,
|
||||
data: { contractId: c.id, reference: c.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Customer-facing lifecycle events ───────────────────────────────────────
|
||||
|
||||
/** Line staff accepted intake → contract is under approval. */
|
||||
accepted(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been accepted and is now under approval. ` +
|
||||
`We will notify you once it is approved.`;
|
||||
void this.notifyContact(c, msg, 'ACCEPTED');
|
||||
this.inApp(c, 'Contract accepted', msg);
|
||||
}
|
||||
|
||||
/** All approval steps complete → contract approved. */
|
||||
approved(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been approved. ` +
|
||||
`The final document will be prepared for signing.`;
|
||||
void this.notifyContact(c, msg, 'APPROVED');
|
||||
this.inApp(c, 'Contract approved', msg);
|
||||
}
|
||||
|
||||
/** Fully executed (all parties signed) → contract active, customer can book. */
|
||||
signedActive(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been signed and is now active. ` +
|
||||
`You can start booking shipments from the portal.`;
|
||||
void this.notifyContact(c, msg, 'SIGNED / ACTIVE');
|
||||
this.inApp(c, 'Contract active', msg);
|
||||
}
|
||||
|
||||
/** Staff rejected the contract. */
|
||||
rejected(c: Contract, reason: string): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} was rejected. Reason: ${reason}. ` +
|
||||
`Please contact us for details.`;
|
||||
void this.notifyContact(c, msg, 'REJECTED');
|
||||
this.inApp(c, 'Contract rejected', msg);
|
||||
}
|
||||
|
||||
/** Staff requested changes before approval. */
|
||||
changesRequested(c: Contract, note: string): void {
|
||||
const msg =
|
||||
`Changes were requested on your contract ${c.reference}: ${note}. ` +
|
||||
`Please update and resubmit from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CHANGES REQUESTED');
|
||||
this.inApp(c, 'Contract changes requested', msg);
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||
dutyAdvised(c: Contract, amount: number, currency: string): void {
|
||||
const msg =
|
||||
`Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` +
|
||||
`Please pay and upload the payment slip from the portal.`;
|
||||
void this.notifyContact(c, msg, 'DUTY ADVISED');
|
||||
this.inApp(c, 'Duty & tax advised', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
/** A clearance document was queried — customer must re-upload it. */
|
||||
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
||||
const msg =
|
||||
`A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` +
|
||||
`${note}. Please re-upload from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED');
|
||||
this.inApp(c, 'Clearance document queried', msg, {
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Import pre-clearance finalized — the process moves to GL Djibouti collection. */
|
||||
preClearanceFinalized(c: Contract): void {
|
||||
const msg =
|
||||
`Pre-clearance for contract ${c.reference} is complete. ` +
|
||||
`Your shipment is proceeding to document collection in Djibouti.`;
|
||||
void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED');
|
||||
this.inApp(c, 'Pre-clearance complete', msg, {
|
||||
type: NotificationType.CLEARANCE_DECISION,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||
|
||||
/** Customer submitted a contract for review. */
|
||||
submittedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'New contract submitted',
|
||||
`Contract ${this.ref(c)} was submitted and is awaiting intake review.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer signed the contract — staff counter-sign is next. */
|
||||
customerSignedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Customer signed contract',
|
||||
`Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`,
|
||||
{ link: `/dashboard/contract-requests/${c.id}/view` },
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded clearance documents — GL review is next. */
|
||||
clearanceDocsUploadedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Clearance documents uploaded',
|
||||
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
|
||||
{
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded the duty/tax payment slip — GL verifies it. */
|
||||
dutySlipUploadedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Duty slip uploaded',
|
||||
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
|
||||
{
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer filed a shipment request under a GENERAL customs contract. */
|
||||
shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'New shipment request',
|
||||
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
|
||||
{
|
||||
link: `/dashboard/shipment-requests/${requestId}`,
|
||||
data: { contractId: c.id, requestId, reference: requestRef },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { 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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user