mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
changes
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
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 { assertDeclarationFiles, buildWorkflowFiles } 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 documentFileKeys = new Set(documents.map((d) => d.fileKey));
|
||||
const workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
documentFileKeys,
|
||||
);
|
||||
|
||||
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');
|
||||
}
|
||||
assertDeclarationFiles(files, tradeDirection);
|
||||
|
||||
for (const file of files) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
file: 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 (!file) throw new BadRequestException('No transit permit uploaded');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'transit_permitted',
|
||||
file,
|
||||
});
|
||||
|
||||
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([
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
]);
|
||||
const filtered: Booking[] = [];
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
const next = this.workflowService.computeNextActionForBooking(b, milestones);
|
||||
if (next?.actor === 'GL_ET') filtered.push(b);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async djQueue(): Promise<Booking[]> {
|
||||
const candidates = await this.bookingsRepository.findByStatuses([
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
]);
|
||||
const filtered: Booking[] = [];
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
const pending = this.workflowService.djPendingMilestoneCodes(milestones);
|
||||
if (b.roHoldReason || pending || this.workflowService.computeNextActionForBooking(b, milestones)?.actor === 'GL_DJ') {
|
||||
filtered.push(b);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user