mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- 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.
577 lines
18 KiB
TypeScript
577 lines
18 KiB
TypeScript
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
import { ContractDocPhase } from '@edr/types';
|
|
|
|
import { ContractsRepository } from './contracts.repository';
|
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
|
import { MilestoneMetadata } from './entities/clearance-milestone.entity';
|
|
import { splitMilestones } from './clearance-milestone.catalog';
|
|
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';
|
|
|
|
export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS';
|
|
|
|
export interface ClearanceNextAction {
|
|
actor: ClearanceActorRole;
|
|
action: string;
|
|
milestoneCode?: string | null;
|
|
blockedReason?: string | null;
|
|
}
|
|
|
|
const IMPORT_BOUNDARY = 'DO_COLLECTED';
|
|
const EXPORT_BOUNDARY = 'EXPORT_RELEASED';
|
|
|
|
const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED';
|
|
const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED';
|
|
|
|
@Injectable()
|
|
export class ClearanceWorkflowService {
|
|
constructor(
|
|
private readonly contractsRepository: ContractsRepository,
|
|
private readonly milestoneService: ClearanceMilestoneService,
|
|
private readonly bookingsRepository: BookingsRepository,
|
|
private readonly notifier: BookingLifecycleNotifierService,
|
|
) {}
|
|
|
|
boundaryMilestone(tradeDirection: string): string {
|
|
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
|
|
}
|
|
|
|
// ── Contract scope (ONE_TIME) ─────────────────────────────────────────────
|
|
|
|
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
|
|
return this.milestoneService.listForContract(contractId);
|
|
}
|
|
|
|
async listMilestonesForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
|
return this.milestoneService.listForBooking(bookingId);
|
|
}
|
|
|
|
async isBoundaryComplete(contractId: string, tradeDirection: string): Promise<boolean> {
|
|
return this.isBoundaryCompleteForMilestones(
|
|
await this.listMilestones(contractId),
|
|
tradeDirection,
|
|
);
|
|
}
|
|
|
|
async isBoundaryCompleteForBooking(
|
|
bookingId: string,
|
|
tradeDirection: string,
|
|
): Promise<boolean> {
|
|
return this.isBoundaryCompleteForMilestones(
|
|
await this.listMilestonesForBooking(bookingId),
|
|
tradeDirection,
|
|
);
|
|
}
|
|
|
|
private isBoundaryCompleteForMilestones(
|
|
milestones: ClearanceMilestone[],
|
|
tradeDirection: string,
|
|
): boolean {
|
|
const code = this.boundaryMilestone(tradeDirection);
|
|
const m = milestones.find((x) => x.milestoneCode === code);
|
|
return m?.status === 'COMPLETED';
|
|
}
|
|
|
|
async assertBoundaryComplete(contract: Contract): Promise<void> {
|
|
const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection);
|
|
if (!ok) {
|
|
throw new BadRequestException(
|
|
`Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async assertPriorComplete(
|
|
contractId: string,
|
|
tradeDirection: string,
|
|
targetCode: string,
|
|
): Promise<void> {
|
|
await this.assertPriorCompleteOnMilestones(
|
|
await this.listMilestones(contractId),
|
|
tradeDirection,
|
|
targetCode,
|
|
);
|
|
}
|
|
|
|
async assertPriorCompleteForBooking(
|
|
bookingId: string,
|
|
tradeDirection: string,
|
|
targetCode: string,
|
|
): Promise<void> {
|
|
await this.assertPriorCompleteOnMilestones(
|
|
await this.listMilestonesForBooking(bookingId),
|
|
tradeDirection,
|
|
targetCode,
|
|
);
|
|
}
|
|
|
|
private async assertPriorCompleteOnMilestones(
|
|
milestones: ClearanceMilestone[],
|
|
tradeDirection: string,
|
|
targetCode: string,
|
|
): Promise<void> {
|
|
const { preBooking } = splitMilestones(tradeDirection);
|
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
|
const targetIdx = preBooking.findIndex((d) => d.code === targetCode);
|
|
if (targetIdx < 0) return;
|
|
|
|
for (let i = 0; i < targetIdx; i++) {
|
|
|
|
const code = preBooking[i]!.code;
|
|
const m = byCode.get(code);
|
|
if (!m) continue;
|
|
if (m.status === 'SKIPPED') continue;
|
|
if (m.status !== 'COMPLETED') {
|
|
throw new BadRequestException(
|
|
`Complete "${preBooking[i]!.label}" before proceeding.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async skipMilestones(contractId: string, codes: string[]): Promise<void> {
|
|
for (const code of codes) {
|
|
await this.milestoneService.skipForContract(contractId, code);
|
|
}
|
|
}
|
|
|
|
async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise<void> {
|
|
for (const code of codes) {
|
|
await this.milestoneService.skipForBooking(bookingId, code);
|
|
}
|
|
}
|
|
|
|
async completeMilestone(
|
|
contractId: string,
|
|
code: string,
|
|
userId?: string,
|
|
metadata?: MilestoneMetadata,
|
|
): Promise<ClearanceMilestone> {
|
|
if (metadata && Object.keys(metadata).length > 0) {
|
|
return this.milestoneService.completeWithMetadataForContract(
|
|
contractId,
|
|
code,
|
|
metadata,
|
|
userId,
|
|
);
|
|
}
|
|
return this.milestoneService.completeForContract(contractId, code, userId);
|
|
}
|
|
|
|
async completeMilestoneForBooking(
|
|
bookingId: string,
|
|
code: string,
|
|
userId?: string,
|
|
metadata?: MilestoneMetadata,
|
|
): Promise<ClearanceMilestone> {
|
|
if (metadata && Object.keys(metadata).length > 0) {
|
|
return this.milestoneService.completeWithMetadataForBooking(
|
|
bookingId,
|
|
code,
|
|
metadata,
|
|
userId,
|
|
);
|
|
}
|
|
return this.milestoneService.completeForBooking(bookingId, code, userId);
|
|
}
|
|
|
|
async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise<void> {
|
|
const uploaded =
|
|
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
|
await this.completeMilestone(contractId, uploaded);
|
|
await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW');
|
|
}
|
|
|
|
async onCustomerDocsUploadedForBooking(
|
|
bookingId: string,
|
|
tradeDirection: string,
|
|
): Promise<void> {
|
|
const uploaded =
|
|
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
|
await this.completeMilestoneForBooking(bookingId, uploaded);
|
|
await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW');
|
|
}
|
|
|
|
async onAllDocsApproved(contractId: string): Promise<void> {
|
|
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
|
|
}
|
|
|
|
async onAllDocsApprovedForBooking(bookingId: string): Promise<void> {
|
|
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
|
}
|
|
|
|
/** Customer doc queried or re-uploaded — document approval milestone must reopen. */
|
|
async onDocumentReviewReopened(contractId: string): Promise<void> {
|
|
await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED');
|
|
}
|
|
|
|
async onDocumentReviewReopenedForBooking(bookingId: string): Promise<void> {
|
|
await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
|
}
|
|
|
|
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
|
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
|
await this.completeMilestone(contractId, 'DECLARED', userId);
|
|
}
|
|
|
|
async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise<void> {
|
|
await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE');
|
|
await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId);
|
|
}
|
|
|
|
async onDutySkipped(contractId: string): Promise<void> {
|
|
await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
|
|
}
|
|
|
|
async onDutySkippedForBooking(bookingId: string): Promise<void> {
|
|
await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
|
|
}
|
|
|
|
async onExportReleased(contractId: string, userId?: string): Promise<void> {
|
|
await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId);
|
|
await this.markReadyForBooking(contractId);
|
|
}
|
|
|
|
async onExportReleasedForBooking(bookingId: string, userId?: string): Promise<void> {
|
|
await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId);
|
|
await this.markReadyForOperation(bookingId);
|
|
}
|
|
|
|
async markReadyForBooking(contractId: string): Promise<void> {
|
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
|
await this.contractsRepository.update(contractId, {
|
|
status: 'CLEARANCE_READY_FOR_BOOKING',
|
|
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
|
|
} as never);
|
|
if (cycle) {
|
|
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
|
|
clearanceReadyAt: new Date(),
|
|
currentPhase: ContractDocPhase.GlEtPostClearance,
|
|
});
|
|
await this.contractsRepository.updateCycle(cycle.id, {
|
|
currentPhase: ContractDocPhase.GlEtPostClearance,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** GENERAL per-booking: boundary complete → customer may proceed to operations. */
|
|
async markReadyForOperation(bookingId: string): Promise<void> {
|
|
await this.bookingsRepository.update(bookingId, {
|
|
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(
|
|
contract: Contract,
|
|
cycle: ContractClearanceCycle | null,
|
|
milestones: ClearanceMilestone[],
|
|
): ContractDocPhase {
|
|
const meta: ClearanceMetaState = {
|
|
dutyRequired: cycle?.dutyRequired ?? null,
|
|
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
|
roHoldReason: cycle?.roHoldReason ?? null,
|
|
currentPhase: cycle?.currentPhase ?? null,
|
|
};
|
|
return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones);
|
|
}
|
|
|
|
resolvePhaseForBooking(
|
|
booking: Booking,
|
|
milestones: ClearanceMilestone[],
|
|
): ContractDocPhase {
|
|
return this.resolvePhaseFromMeta(
|
|
booking.tradeDirection ?? 'IMPORT',
|
|
metaFromBooking(booking),
|
|
milestones,
|
|
);
|
|
}
|
|
|
|
private resolvePhaseFromMeta(
|
|
tradeDirection: string,
|
|
meta: ClearanceMetaState,
|
|
milestones: ClearanceMilestone[],
|
|
): ContractDocPhase {
|
|
if (meta.currentPhase) {
|
|
return meta.currentPhase as ContractDocPhase;
|
|
}
|
|
return this.inferPhaseFromMeta(tradeDirection, meta, milestones);
|
|
}
|
|
|
|
inferPhase(
|
|
contract: Contract,
|
|
cycle: ContractClearanceCycle | null,
|
|
milestones: ClearanceMilestone[],
|
|
): ContractDocPhase {
|
|
return this.inferPhaseFromMeta(
|
|
contract.tradeDirection,
|
|
{
|
|
dutyRequired: cycle?.dutyRequired ?? null,
|
|
roHoldReason: cycle?.roHoldReason ?? null,
|
|
},
|
|
milestones,
|
|
);
|
|
}
|
|
|
|
inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase {
|
|
return this.inferPhaseFromMeta(
|
|
booking.tradeDirection ?? 'IMPORT',
|
|
metaFromBooking(booking),
|
|
milestones,
|
|
);
|
|
}
|
|
|
|
private inferPhaseFromMeta(
|
|
tradeDirection: string,
|
|
meta: ClearanceMetaState,
|
|
milestones: ClearanceMilestone[],
|
|
): ContractDocPhase {
|
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
|
const isDone = (code: string) =>
|
|
byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED';
|
|
|
|
const docUploaded =
|
|
tradeDirection === 'IMPORT'
|
|
? isDone(IMPORT_DOC_UPLOADED)
|
|
: isDone(EXPORT_DOC_UPLOADED);
|
|
|
|
if (!docUploaded) return ContractDocPhase.CustomerIntake;
|
|
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
|
|
|
if (tradeDirection === 'EXPORT') {
|
|
if (!isDone('RELEASE_ORDER_SECURED')) {
|
|
return ContractDocPhase.GlDjCollection;
|
|
}
|
|
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
|
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
|
|
return ContractDocPhase.GlEtPostClearance;
|
|
}
|
|
|
|
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
|
if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
|
|
return ContractDocPhase.CustomerDuty;
|
|
}
|
|
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
|
|
if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance;
|
|
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
|
|
return ContractDocPhase.GlEtPostClearance;
|
|
}
|
|
|
|
computeNextAction(
|
|
contract: Contract,
|
|
cycle: ContractClearanceCycle | null,
|
|
milestones: ClearanceMilestone[],
|
|
): ClearanceNextAction | null {
|
|
return this.computeNextActionFromMeta(
|
|
contract.tradeDirection,
|
|
{
|
|
dutyRequired: cycle?.dutyRequired ?? null,
|
|
roHoldReason: cycle?.roHoldReason ?? null,
|
|
preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null,
|
|
},
|
|
milestones,
|
|
'contract',
|
|
);
|
|
}
|
|
|
|
computeNextActionForBooking(
|
|
booking: Booking,
|
|
milestones: ClearanceMilestone[],
|
|
): ClearanceNextAction | null {
|
|
return this.computeNextActionFromMeta(
|
|
booking.tradeDirection ?? 'IMPORT',
|
|
metaFromBooking(booking),
|
|
milestones,
|
|
'booking',
|
|
);
|
|
}
|
|
|
|
private computeNextActionFromMeta(
|
|
tradeDirection: string,
|
|
meta: ClearanceMetaState,
|
|
milestones: ClearanceMilestone[],
|
|
terminalScope: 'contract' | 'booking',
|
|
): ClearanceNextAction | null {
|
|
if (meta.roHoldReason) {
|
|
return {
|
|
actor: 'GL_DJ',
|
|
action: 'Re-upload Release Order or request port amendment',
|
|
milestoneCode: 'RELEASE_ORDER_SECURED',
|
|
blockedReason: meta.roHoldReason,
|
|
};
|
|
}
|
|
|
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
|
const pending = (code: string) => {
|
|
const m = byCode.get(code);
|
|
return m && m.status === 'PENDING';
|
|
};
|
|
const isDone = (code: string) => {
|
|
const m = byCode.get(code);
|
|
return m?.status === 'COMPLETED' || m?.status === 'SKIPPED';
|
|
};
|
|
|
|
const docCode =
|
|
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
|
|
|
if (pending(docCode) || !isDone(docCode)) {
|
|
return {
|
|
actor: 'CUSTOMER',
|
|
action: 'Upload clearance documents',
|
|
milestoneCode: docCode,
|
|
};
|
|
}
|
|
|
|
if (!isDone('DOCUMENTS_APPROVED')) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Review and approve customer documents',
|
|
milestoneCode: 'DOCUMENTS_APPROVED',
|
|
};
|
|
}
|
|
|
|
const terminalAction =
|
|
terminalScope === 'contract'
|
|
? 'Create shipment booking'
|
|
: 'Proceed to request operation';
|
|
|
|
if (tradeDirection === 'EXPORT') {
|
|
if (!isDone('RELEASE_ORDER_SECURED')) {
|
|
return {
|
|
actor: 'GL_DJ',
|
|
action: 'Upload Release Order and vessel departure date',
|
|
milestoneCode: 'RELEASE_ORDER_SECURED',
|
|
};
|
|
}
|
|
if (!isDone('DECLARED')) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Upload customs declaration documents',
|
|
milestoneCode: 'DECLARED',
|
|
};
|
|
}
|
|
if (!isDone(EXPORT_BOUNDARY)) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Confirm export release',
|
|
milestoneCode: EXPORT_BOUNDARY,
|
|
};
|
|
}
|
|
if (terminalScope === 'booking') {
|
|
if (!isDone('FREIGHT_PAYMENT_SETTLED')) {
|
|
return {
|
|
actor: 'CUSTOMER',
|
|
action: 'Pay freight charges',
|
|
milestoneCode: 'FREIGHT_PAYMENT_SETTLED',
|
|
};
|
|
}
|
|
if (!isDone('WAGON_ALLOCATED')) {
|
|
return {
|
|
actor: 'OPERATIONS',
|
|
action: 'Allocate wagon',
|
|
milestoneCode: 'WAGON_ALLOCATED',
|
|
};
|
|
}
|
|
if (!isDone('EXPORT_TRANSPORT_ISSUED')) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Upload transit permit',
|
|
milestoneCode: 'EXPORT_TRANSPORT_ISSUED',
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
return {
|
|
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
|
action: terminalAction,
|
|
milestoneCode: EXPORT_BOUNDARY,
|
|
};
|
|
}
|
|
|
|
if (!isDone('DECLARED')) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Upload customs declaration documents',
|
|
milestoneCode: 'DECLARED',
|
|
};
|
|
}
|
|
|
|
if (meta.dutyRequired === null || meta.dutyRequired === undefined) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Set whether duty/tax applies',
|
|
milestoneCode: 'DUTY_TAXES_ADVISED',
|
|
};
|
|
}
|
|
|
|
if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) {
|
|
if (!isDone('DUTY_TAXES_ADVISED')) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Advise duty and tax amount',
|
|
milestoneCode: 'DUTY_TAXES_ADVISED',
|
|
};
|
|
}
|
|
return {
|
|
actor: 'CUSTOMER',
|
|
action: 'Upload duty/tax payment slip',
|
|
milestoneCode: 'DUTY_TAX_PAID',
|
|
};
|
|
}
|
|
|
|
if (!isDone('TRANSIT_PERMIT_UPLOADED')) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Upload transit permit screenshot',
|
|
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
|
};
|
|
}
|
|
|
|
if (!meta.preClearanceFinalizedAt) {
|
|
return {
|
|
actor: 'GL_ET',
|
|
action: 'Finalize pre-clearance',
|
|
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
|
};
|
|
}
|
|
|
|
if (!isDone(IMPORT_BOUNDARY)) {
|
|
return {
|
|
actor: 'GL_DJ',
|
|
action: 'Upload Delivery Order',
|
|
milestoneCode: IMPORT_BOUNDARY,
|
|
};
|
|
}
|
|
|
|
return {
|
|
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
|
action: terminalAction,
|
|
milestoneCode: IMPORT_BOUNDARY,
|
|
};
|
|
}
|
|
|
|
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
|
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
|
|
return pending?.milestoneCode ?? null;
|
|
}
|
|
|
|
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
|
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
|
|
return pending?.milestoneCode ?? null;
|
|
}
|
|
}
|