mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
619 lines
21 KiB
TypeScript
619 lines
21 KiB
TypeScript
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
import { ContractDocPhase } from '@edr/types';
|
|
|
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
|
import { FilesService } from '../files/files.service';
|
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
|
import { BookingsService } from '../bookings/bookings.service';
|
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
|
import { Booking } from '../bookings/entities/booking.entity';
|
|
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
|
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
|
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
|
|
|
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
|
|
|
export interface BookingClearanceView {
|
|
bookingId: string;
|
|
status: string;
|
|
includesCustoms: boolean;
|
|
inputCode: string | null;
|
|
outputCode: string | null;
|
|
documents: Array<{
|
|
fileKey: string;
|
|
label: string;
|
|
required: boolean;
|
|
uploadedBy: 'customer' | 'gl';
|
|
settingCode: string;
|
|
file: { id: string; name: string; url: string } | null;
|
|
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
|
note: string | null;
|
|
}>;
|
|
allApproved: boolean;
|
|
phase?: string | null;
|
|
milestones?: Array<{
|
|
id: string;
|
|
milestoneCode: string;
|
|
milestoneLabel: string;
|
|
status: string;
|
|
ownerRegion?: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
sortOrder: number;
|
|
}>;
|
|
nextAction?: {
|
|
actor: string;
|
|
action: string;
|
|
milestoneCode?: string | null;
|
|
blockedReason?: string | null;
|
|
} | null;
|
|
dutyRequired?: boolean | null;
|
|
roHold?: boolean;
|
|
roHoldReason?: string | null;
|
|
vesselDepartureDate?: string | null;
|
|
roAmendmentRequestedAt?: string | null;
|
|
operationReady?: boolean;
|
|
preClearanceFinalized?: boolean;
|
|
dutyAdvice?: {
|
|
amount: number;
|
|
currency: string;
|
|
declarationSerial?: string | null;
|
|
noticeFile?: { id: string; name: string; url: string } | null;
|
|
} | null;
|
|
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
|
}
|
|
|
|
@Injectable()
|
|
export class BookingClearanceService {
|
|
constructor(
|
|
private readonly bookingsRepository: BookingsRepository,
|
|
private readonly bookingsService: BookingsService,
|
|
private readonly filesService: FilesService,
|
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
|
private readonly workflowService: ClearanceWorkflowService,
|
|
private readonly milestoneService: ClearanceMilestoneService,
|
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
|
) {}
|
|
|
|
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
|
if (!booking.customsClearingEnabled) {
|
|
throw new BadRequestException('Phased clearance applies only to customs bookings.');
|
|
}
|
|
if (booking.contractKind !== 'GENERAL') {
|
|
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
|
|
}
|
|
if (!booking.contractId) {
|
|
throw new BadRequestException('Booking is not linked to a contract.');
|
|
}
|
|
}
|
|
|
|
private async loadBooking(bookingId: string): Promise<Booking> {
|
|
const booking = await this.bookingsService.findById(bookingId);
|
|
await this.assertPhasedGeneralCustoms(booking);
|
|
return booking;
|
|
}
|
|
|
|
async getClearanceView(bookingId: string): Promise<BookingClearanceView> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking);
|
|
|
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
|
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
|
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
|
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
|
|
|
const documents: BookingClearanceView['documents'] = [];
|
|
|
|
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
|
|
if (!code) return;
|
|
let setting;
|
|
try {
|
|
setting = await this.fileUploadSettingsService.getByCode(code);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const field of setting.fields ?? []) {
|
|
const file = fileByCode.get(field.fileKey) ?? null;
|
|
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
|
documents.push({
|
|
fileKey: field.fileKey,
|
|
label: field.fileLabel,
|
|
required: field.isRequired,
|
|
uploadedBy,
|
|
settingCode: code,
|
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
|
reviewStatus: review?.status ?? null,
|
|
note: review?.note ?? null,
|
|
});
|
|
}
|
|
};
|
|
|
|
await pushSetting(inputCode, 'customer');
|
|
await pushSetting(outputCode, 'gl');
|
|
|
|
for (const f of files) {
|
|
if (!f.code?.startsWith('custom_')) continue;
|
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
|
documents.push({
|
|
fileKey: f.code,
|
|
label: f.name,
|
|
required: false,
|
|
uploadedBy: 'customer',
|
|
settingCode: 'custom',
|
|
file: { id: f.id, name: f.name, url: f.url },
|
|
reviewStatus: review?.status ?? null,
|
|
note: review?.note ?? null,
|
|
});
|
|
}
|
|
|
|
const allApproved = await this.isClearanceFullyApproved(booking);
|
|
const 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(
|
|
bookingId,
|
|
booking.tradeDirection ?? 'IMPORT',
|
|
);
|
|
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
|
const workflowFiles = buildWorkflowFiles(
|
|
files,
|
|
booking.tradeDirection ?? 'IMPORT',
|
|
);
|
|
|
|
return {
|
|
bookingId,
|
|
status: booking.status,
|
|
includesCustoms,
|
|
inputCode,
|
|
outputCode,
|
|
documents,
|
|
allApproved,
|
|
phase,
|
|
milestones: milestones.map((m) => ({
|
|
id: m.id,
|
|
milestoneCode: m.milestoneCode,
|
|
milestoneLabel: m.milestoneLabel,
|
|
status: m.status,
|
|
ownerRegion: m.ownerRegion,
|
|
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
|
sortOrder: m.sortOrder,
|
|
})),
|
|
nextAction,
|
|
dutyRequired: booking.dutyRequired ?? null,
|
|
roHold: Boolean(booking.roHoldReason),
|
|
roHoldReason: booking.roHoldReason ?? null,
|
|
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
|
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
|
? booking.roAmendmentRequestedAt.toISOString()
|
|
: null,
|
|
operationReady: boundary,
|
|
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
|
|
dutyAdvice,
|
|
workflowFiles,
|
|
};
|
|
}
|
|
|
|
private buildDutyAdvice(
|
|
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
|
milestones: ClearanceMilestone[],
|
|
): BookingClearanceView['dutyAdvice'] {
|
|
const advised = milestones.find(
|
|
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
|
|
);
|
|
if (!advised?.metadata) return null;
|
|
const amount = advised.metadata.dutyAmount;
|
|
const currency = advised.metadata.dutyCurrency;
|
|
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
|
|
const notice = files.find((f) => f.code === 'duty_tax_notice');
|
|
return {
|
|
amount,
|
|
currency,
|
|
declarationSerial:
|
|
typeof advised.metadata.declarationSerial === 'string'
|
|
? advised.metadata.declarationSerial
|
|
: null,
|
|
noticeFile: notice
|
|
? { id: notice.id, name: notice.name, url: notice.url }
|
|
: null,
|
|
};
|
|
}
|
|
|
|
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
|
const { inputCode } = clearanceCodesForBooking(booking);
|
|
if (!inputCode) return true;
|
|
let setting;
|
|
try {
|
|
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
|
if (required.length === 0) return true;
|
|
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
|
|
return required.every((field) =>
|
|
reviews.some(
|
|
(r) =>
|
|
r.settingCode === inputCode &&
|
|
r.fileKey === field.fileKey &&
|
|
r.status === 'APPROVED',
|
|
),
|
|
);
|
|
}
|
|
|
|
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
|
|
return (
|
|
Boolean(booking.customsClearingEnabled) &&
|
|
booking.contractKind === 'GENERAL' &&
|
|
Boolean(booking.contractId)
|
|
);
|
|
}
|
|
|
|
async uploadDeclaration(
|
|
bookingId: string,
|
|
files: Express.Multer.File[],
|
|
userId?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
|
const allApproved = await this.isClearanceFullyApproved(booking);
|
|
if (!allApproved) {
|
|
throw new BadRequestException(
|
|
'All required customer documents must be approved before uploading a declaration.',
|
|
);
|
|
}
|
|
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
|
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
|
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
|
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
|
}
|
|
await this.workflowService.assertPriorCompleteForBooking(
|
|
bookingId,
|
|
tradeDirection,
|
|
'UNDER_CUSTOMS_CLEARANCE',
|
|
);
|
|
|
|
if (files.length === 0) {
|
|
throw new BadRequestException('No declaration documents uploaded');
|
|
}
|
|
|
|
await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files);
|
|
|
|
await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase:
|
|
tradeDirection === 'EXPORT'
|
|
? ContractDocPhase.GlEtPostClearance
|
|
: ContractDocPhase.CustomerDuty,
|
|
} as never);
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async adviseDuty(
|
|
bookingId: string,
|
|
dto: AdviseContractDutyDto,
|
|
userId?: string,
|
|
attachment?: Express.Multer.File,
|
|
): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'IMPORT') {
|
|
throw new BadRequestException('Duty advice applies only to import bookings.');
|
|
}
|
|
await this.workflowService.assertPriorCompleteForBooking(
|
|
bookingId,
|
|
'IMPORT',
|
|
'DUTY_TAXES_ADVISED',
|
|
);
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
dutyRequired: dto.dutyRequired,
|
|
clearanceCurrentPhase: dto.dutyRequired
|
|
? ContractDocPhase.CustomerDuty
|
|
: ContractDocPhase.GlEtPostClearance,
|
|
} as never);
|
|
|
|
if (!dto.dutyRequired) {
|
|
await this.workflowService.onDutySkippedForBooking(bookingId);
|
|
} else {
|
|
if (dto.amount == null || dto.amount < 0) {
|
|
throw new BadRequestException('Duty amount is required when duty applies.');
|
|
}
|
|
if (!attachment) {
|
|
throw new BadRequestException('Duty notice attachment is required when duty applies.');
|
|
}
|
|
await this.filesService.upsertByCode({
|
|
resourceId: bookingId,
|
|
resource: 'bookings',
|
|
code: 'duty_tax_notice',
|
|
file: attachment,
|
|
});
|
|
await this.milestoneService.adviseDuty(
|
|
bookingId,
|
|
{
|
|
amount: dto.amount,
|
|
currency: dto.currency ?? 'ETB',
|
|
declarationSerial: dto.declarationSerial,
|
|
},
|
|
userId,
|
|
);
|
|
}
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'IMPORT') {
|
|
throw new BadRequestException('Duty slip upload applies only to import bookings.');
|
|
}
|
|
if (!booking.dutyRequired) {
|
|
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
|
}
|
|
if (!file) throw new BadRequestException('No payment slip uploaded');
|
|
|
|
await this.filesService.upsertByCode({
|
|
resourceId: bookingId,
|
|
resource: 'bookings',
|
|
code: 'duty_tax_receipt',
|
|
file,
|
|
});
|
|
|
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID');
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
|
} as never);
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async uploadTransitPermit(
|
|
bookingId: string,
|
|
files: Express.Multer.File[],
|
|
userId?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'IMPORT') {
|
|
throw new BadRequestException('Transit permit applies only to import bookings.');
|
|
}
|
|
await this.workflowService.assertPriorCompleteForBooking(
|
|
bookingId,
|
|
'IMPORT',
|
|
'TRANSIT_PERMIT_UPLOADED',
|
|
);
|
|
if (files.length === 0) {
|
|
throw new BadRequestException('No transit permit documents uploaded');
|
|
}
|
|
|
|
await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files);
|
|
|
|
await this.workflowService.completeMilestoneForBooking(
|
|
bookingId,
|
|
'TRANSIT_PERMIT_UPLOADED',
|
|
userId,
|
|
);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
|
} as never);
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async finalizePreClearance(bookingId: string): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'IMPORT') {
|
|
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
|
|
}
|
|
|
|
await this.workflowService.assertPriorCompleteForBooking(
|
|
bookingId,
|
|
'IMPORT',
|
|
'TRANSIT_PERMIT_UPLOADED',
|
|
);
|
|
|
|
if (booking.preClearanceFinalizedAt) {
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
preClearanceFinalizedAt: new Date(),
|
|
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
|
} as never);
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async uploadDeliveryOrder(
|
|
bookingId: string,
|
|
file: Express.Multer.File,
|
|
userId?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'IMPORT') {
|
|
throw new BadRequestException('Delivery Order applies only to import bookings.');
|
|
}
|
|
|
|
if (!booking.preClearanceFinalizedAt) {
|
|
throw new BadRequestException(
|
|
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
|
|
);
|
|
}
|
|
|
|
await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED');
|
|
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
|
|
|
await this.filesService.upsertByCode({
|
|
resourceId: bookingId,
|
|
resource: 'bookings',
|
|
code: 'delivery_order',
|
|
file,
|
|
});
|
|
|
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
|
await this.workflowService.markReadyForOperation(bookingId);
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
private async resolveRoMinDays(): Promise<number> {
|
|
try {
|
|
const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
|
|
const first = setting.children?.[0];
|
|
const n = Number(first?.value);
|
|
return Number.isFinite(n) && n > 0 ? n : 2;
|
|
} catch {
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
private daysUntil(dateStr: string): number {
|
|
const target = new Date(dateStr);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
target.setHours(0, 0, 0, 0);
|
|
return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
|
|
}
|
|
|
|
async uploadReleaseOrder(
|
|
bookingId: string,
|
|
file: Express.Multer.File,
|
|
vesselDepartureDate: string,
|
|
userId?: string,
|
|
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'EXPORT') {
|
|
throw new BadRequestException('Release Order applies only to export bookings.');
|
|
}
|
|
await this.workflowService.assertPriorCompleteForBooking(
|
|
bookingId,
|
|
'EXPORT',
|
|
'RELEASE_ORDER_SECURED',
|
|
);
|
|
|
|
if (!file) throw new BadRequestException('No Release Order uploaded');
|
|
if (!vesselDepartureDate?.trim()) {
|
|
throw new BadRequestException('Vessel departure date is required');
|
|
}
|
|
|
|
const minDays = await this.resolveRoMinDays();
|
|
const leadDays = this.daysUntil(vesselDepartureDate);
|
|
|
|
await this.filesService.upsertByCode({
|
|
resourceId: bookingId,
|
|
resource: 'bookings',
|
|
code: 'release_order',
|
|
file,
|
|
});
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
vesselDepartureDate,
|
|
roAmendmentRequestedAt: null,
|
|
} as never);
|
|
|
|
if (leadDays < minDays) {
|
|
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
|
|
await this.bookingsRepository.update(bookingId, {
|
|
roHoldReason: reason,
|
|
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
|
} as never);
|
|
return {
|
|
booking: await this.bookingsService.findById(bookingId),
|
|
hold: true,
|
|
holdReason: reason,
|
|
};
|
|
}
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
roHoldReason: null,
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
|
} as never);
|
|
await this.workflowService.completeMilestoneForBooking(
|
|
bookingId,
|
|
'RELEASE_ORDER_SECURED',
|
|
userId,
|
|
);
|
|
|
|
return { booking: await this.bookingsService.findById(bookingId), hold: false };
|
|
}
|
|
|
|
async requestRoAmendment(
|
|
bookingId: string,
|
|
note?: string,
|
|
userId?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'EXPORT') {
|
|
throw new BadRequestException('RO amendment applies only to export bookings.');
|
|
}
|
|
|
|
const reason =
|
|
note?.trim() ||
|
|
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
roAmendmentRequestedAt: new Date(),
|
|
roHoldReason: reason,
|
|
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
|
} as never);
|
|
|
|
if (userId) {
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
reason,
|
|
'CHANGES_REQUESTED',
|
|
userId,
|
|
);
|
|
}
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async confirmExportRelease(bookingId: string, userId?: string): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'EXPORT') {
|
|
throw new BadRequestException('Export release applies only to export bookings.');
|
|
}
|
|
await this.workflowService.assertPriorCompleteForBooking(
|
|
bookingId,
|
|
'EXPORT',
|
|
'EXPORT_RELEASED',
|
|
);
|
|
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async etQueue(): Promise<Booking[]> {
|
|
const candidates = await this.bookingsRepository.findByStatuses([
|
|
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
|
|
]);
|
|
const filtered: Booking[] = [];
|
|
for (const b of candidates) {
|
|
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
|
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
async djQueue(): Promise<Booking[]> {
|
|
const candidates = await this.bookingsRepository.findByStatuses([
|
|
...DJ_BOOKING_QUEUE_STATUSES,
|
|
]);
|
|
const filtered: Booking[] = [];
|
|
for (const b of candidates) {
|
|
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
|
if (
|
|
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
|
|
roHoldReason: b.roHoldReason,
|
|
preClearanceFinalizedAt: b.preClearanceFinalizedAt,
|
|
})
|
|
) {
|
|
filtered.push(b);
|
|
}
|
|
}
|
|
return filtered;
|
|
}
|
|
}
|