mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
changes
This commit is contained in:
@@ -58,7 +58,6 @@ export function buildCargoTypeTree(
|
||||
id: child.id,
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
show_free_text_box: child.showFreeTextBox,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -35,6 +35,8 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -128,6 +130,8 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -196,6 +200,8 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('BookingTransitionService — operation review', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
@@ -42,8 +45,16 @@ export class BookingTransitionService {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
@Inject(forwardRef(() => BookingClearanceService))
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
) {}
|
||||
|
||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||
}
|
||||
|
||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||
@@ -520,8 +531,19 @@ export class BookingTransitionService {
|
||||
note: string | null;
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: unknown[];
|
||||
nextAction?: unknown;
|
||||
dutyRequired?: boolean | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
operationReady?: boolean;
|
||||
}> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
return this.bookingClearanceService.getClearanceView(bookingId);
|
||||
}
|
||||
const { inputCode, outputCode, includesCustoms } =
|
||||
clearanceCodesForBooking(booking);
|
||||
|
||||
@@ -671,6 +693,17 @@ export class BookingTransitionService {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -746,8 +779,30 @@ export class BookingTransitionService {
|
||||
'CHANGES_REQUESTED',
|
||||
staffId,
|
||||
);
|
||||
if (this.isPhasedGeneralCustoms(booking) && booking.preClearanceFinalizedAt) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
preClearanceFinalizedAt: null,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
return this.bookingsService.findById(bookingId);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(updated)) {
|
||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||
if (allApproved) {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
const phase =
|
||||
updated.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: phase,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
||||
@@ -781,6 +836,11 @@ export class BookingTransitionService {
|
||||
*/
|
||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
throw new BadRequestException(
|
||||
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(booking);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
@@ -19,7 +20,7 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -33,6 +34,11 @@ import type { Response } from 'express';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
RoAmendmentDto,
|
||||
} from '../contracts/dto/phased-clearance.dto';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
@@ -72,6 +78,7 @@ export class BookingsController {
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -344,6 +351,20 @@ export class BookingsController {
|
||||
|
||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
|
||||
getBookingEtClearanceQueue() {
|
||||
return this.bookingClearanceService.etQueue();
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' })
|
||||
getBookingDjClearanceQueue() {
|
||||
return this.bookingClearanceService.djQueue();
|
||||
}
|
||||
|
||||
@Get(':id/clearance')
|
||||
@ApiOperation({
|
||||
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
||||
@@ -451,6 +472,160 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' })
|
||||
async uploadBookingDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeclaration(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' })
|
||||
async adviseBookingDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||||
const dto: AdviseContractDutyDto = {
|
||||
dutyRequired,
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
};
|
||||
const booking = await this.bookingClearanceService.adviseDuty(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
attachment,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
|
||||
async uploadBookingDutySlip(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-permit')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingTransitPermit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/delivery-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingReleaseOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
file,
|
||||
vesselDepartureDate,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return {
|
||||
...this.transitionService.enrichBookingResponse(result.booking),
|
||||
hold: result.hold,
|
||||
holdReason: result.holdReason,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/clearance/ro-amendment')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/export-release')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
async confirmBookingExportRelease(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.confirmExportRelease(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||
|
||||
@@ -38,6 +38,7 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -54,6 +55,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
|
||||
@@ -490,6 +490,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Bookings in any of the given statuses (clearance queue helpers). */
|
||||
async findByStatuses(statuses: string[]): Promise<Booking[]> {
|
||||
if (!statuses.length) return [];
|
||||
return this.repository.find({
|
||||
where: { status: In(statuses) },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||
async findQueue(options: {
|
||||
status: string | string[];
|
||||
|
||||
@@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto {
|
||||
@ApiProperty({ example: 'BULK_COFFEE' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
show_free_text_box!: boolean;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
||||
unit_of_measure?: CargoUnitOfMeasure | null;
|
||||
}
|
||||
|
||||
@@ -447,6 +447,25 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
|
||||
glStationYardId?: string | null;
|
||||
|
||||
/** Per-booking phased clearance (GENERAL + customs). */
|
||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
clearanceCurrentPhase?: string | null;
|
||||
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ro_hold_reason', type: 'text', nullable: true })
|
||||
roHoldReason?: string | null;
|
||||
|
||||
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
|
||||
/** GL staff user bound to this shipment by the station manager. */
|
||||
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||
glAssignedStaffId?: string | null;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
const generalImportBooking = {
|
||||
id: 'b-general',
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'GENERAL',
|
||||
contractId: 'c-1',
|
||||
dutyRequired: true,
|
||||
roHoldReason: null,
|
||||
vesselDepartureDate: null,
|
||||
} as Booking;
|
||||
|
||||
const generalExportBooking = {
|
||||
...generalImportBooking,
|
||||
id: 'b-export',
|
||||
tradeDirection: 'EXPORT',
|
||||
dutyRequired: null,
|
||||
} as Booking;
|
||||
|
||||
function makeService(overrides?: {
|
||||
booking?: Booking;
|
||||
workflowThrows?: boolean;
|
||||
}) {
|
||||
const booking = overrides?.booking ?? generalImportBooking;
|
||||
const bookingsRepository = {
|
||||
findDocumentReviews: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const filesService = {
|
||||
upsertByCode: jest.fn().mockResolvedValue({}),
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const fileUploadSettingsService = {
|
||||
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
|
||||
};
|
||||
const workflowService = {
|
||||
assertPriorCompleteForBooking: overrides?.workflowThrows
|
||||
? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete'))
|
||||
: jest.fn().mockResolvedValue(undefined),
|
||||
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
|
||||
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
|
||||
computeNextActionForBooking: jest.fn().mockReturnValue(null),
|
||||
isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false),
|
||||
markReadyForOperation: jest.fn().mockResolvedValue(undefined),
|
||||
onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const milestoneService = {
|
||||
adviseDuty: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const dropdownSettingsService = {
|
||||
getByCode: jest.fn().mockResolvedValue({
|
||||
children: [{ value: '2' }],
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new BookingClearanceService(
|
||||
bookingsRepository as never,
|
||||
bookingsService as never,
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
workflowService as never,
|
||||
milestoneService as never,
|
||||
dropdownSettingsService as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
bookingsRepository,
|
||||
bookingsService,
|
||||
filesService,
|
||||
workflowService,
|
||||
milestoneService,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BookingClearanceService', () => {
|
||||
describe('adviseDuty', () => {
|
||||
it('skips duty milestones when duty is not required', async () => {
|
||||
const { service, workflowService, bookingsRepository } = makeService();
|
||||
await service.adviseDuty('b-general', { dutyRequired: false });
|
||||
|
||||
expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({
|
||||
dutyRequired: false,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadDutySlip', () => {
|
||||
it('rejects when duty is not required', async () => {
|
||||
const { service } = makeService({
|
||||
booking: { ...generalImportBooking, dutyRequired: false } as Booking,
|
||||
});
|
||||
await expect(
|
||||
service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => {
|
||||
const { service, filesService, workflowService, bookingsRepository } = makeService();
|
||||
const file = { fieldname: 'file' } as Express.Multer.File;
|
||||
|
||||
await service.uploadDutySlip('b-general', file);
|
||||
|
||||
expect(filesService.upsertByCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: 'b-general',
|
||||
code: 'duty_tax_receipt',
|
||||
file,
|
||||
}),
|
||||
);
|
||||
expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
'DUTY_TAX_PAID',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadDeclaration', () => {
|
||||
it('rejects when a prior milestone is incomplete', async () => {
|
||||
const { service } = makeService({ workflowThrows: true });
|
||||
await expect(
|
||||
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadReleaseOrder', () => {
|
||||
it('places RO on hold when vessel departs too soon', async () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateStr = tomorrow.toISOString().slice(0, 10);
|
||||
|
||||
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
|
||||
const result = await service.uploadReleaseOrder(
|
||||
'b-export',
|
||||
{ fieldname: 'ro' } as Express.Multer.File,
|
||||
dateStr,
|
||||
);
|
||||
|
||||
expect(result.hold).toBe(true);
|
||||
expect(result.holdReason).toMatch(/minimum lead time/i);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-export',
|
||||
expect.objectContaining({ roHoldReason: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
@@ -39,6 +39,15 @@ export class ClearanceMilestoneService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */
|
||||
async seedPreBookingMilestonesOnBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const { preBooking } = splitMilestones(tradeDirection);
|
||||
await this.seed(preBooking, { bookingId });
|
||||
}
|
||||
|
||||
/** Seed the post-booking milestones onto a freshly created booking. */
|
||||
async seedPostBookingMilestones(
|
||||
bookingId: string,
|
||||
@@ -94,7 +103,7 @@ export class ClearanceMilestoneService {
|
||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
return milestone;
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
@@ -178,7 +187,7 @@ export class ClearanceMilestoneService {
|
||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
return milestone;
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
@@ -199,6 +208,27 @@ export class ClearanceMilestoneService {
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
async skipForBooking(bookingId: string, code: string): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') return milestone;
|
||||
milestone.status = 'SKIPPED';
|
||||
milestone.triggeredAt = new Date();
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
async completeWithMetadataForBooking(
|
||||
bookingId: string,
|
||||
code: string,
|
||||
metadata: MilestoneMetadata,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
return this.completeWithMetadata(bookingId, code, metadata, userId, note);
|
||||
}
|
||||
|
||||
/** Complete a contract milestone with structured metadata (duty advice, etc.). */
|
||||
async completeWithMetadataForContract(
|
||||
contractId: string,
|
||||
@@ -212,7 +242,7 @@ export class ClearanceMilestoneService {
|
||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
return milestone;
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
function ms(
|
||||
code: string,
|
||||
@@ -37,15 +38,18 @@ function makeService(milestones: ClearanceMilestone[]) {
|
||||
};
|
||||
const milestoneService = {
|
||||
listForContract: jest.fn().mockResolvedValue(milestones),
|
||||
listForBooking: jest.fn().mockResolvedValue(milestones),
|
||||
skipForContract: jest.fn(),
|
||||
completeForContract: jest.fn(),
|
||||
completeWithMetadataForContract: jest.fn(),
|
||||
};
|
||||
const bookingsRepository = { update: jest.fn() };
|
||||
const service = new ClearanceWorkflowService(
|
||||
contractsRepository as never,
|
||||
milestoneService as never,
|
||||
bookingsRepository as never,
|
||||
);
|
||||
return { service, milestoneService, contractsRepository };
|
||||
return { service, milestoneService, contractsRepository, bookingsRepository };
|
||||
}
|
||||
|
||||
const importContract = {
|
||||
@@ -199,7 +203,7 @@ describe('ClearanceWorkflowService', () => {
|
||||
expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED');
|
||||
});
|
||||
|
||||
it('prompts DJ for DO then ET booking when pre-booking complete', () => {
|
||||
it('prompts ET to finalize pre-clearance after transit permit', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
@@ -211,6 +215,26 @@ describe('ClearanceWorkflowService', () => {
|
||||
];
|
||||
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.action).toMatch(/finalize pre-clearance/i);
|
||||
});
|
||||
|
||||
it('prompts DJ for DO then ET booking when pre-booking complete', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
];
|
||||
const cycle = {
|
||||
dutyRequired: false,
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
} as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const djNext = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(djNext?.actor).toBe('GL_DJ');
|
||||
|
||||
@@ -281,4 +305,27 @@ describe('ClearanceWorkflowService', () => {
|
||||
).toBe('DO_COLLECTED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextActionForBooking', () => {
|
||||
it('prompts customer to proceed after import boundary on booking', () => {
|
||||
const booking = {
|
||||
tradeDirection: 'IMPORT',
|
||||
dutyRequired: false,
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
} as Booking;
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||
ms('DO_COLLECTED', 'COMPLETED', 'DJ'),
|
||||
];
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextActionForBooking(booking, milestones);
|
||||
expect(next?.actor).toBe('CUSTOMER');
|
||||
expect(next?.action).toMatch(/operation/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ 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 { 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';
|
||||
|
||||
@@ -29,19 +33,45 @@ export class ClearanceWorkflowService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
) {}
|
||||
|
||||
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 milestones = await this.listMilestones(contractId);
|
||||
const m = milestones.find((x) => x.milestoneCode === code);
|
||||
return m?.status === 'COMPLETED';
|
||||
}
|
||||
@@ -59,14 +89,38 @@ export class ClearanceWorkflowService {
|
||||
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 milestones = await this.listMilestones(contractId);
|
||||
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;
|
||||
@@ -85,6 +139,12 @@ export class ClearanceWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -102,6 +162,23 @@ export class ClearanceWorkflowService {
|
||||
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;
|
||||
@@ -109,24 +186,52 @@ export class ClearanceWorkflowService {
|
||||
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');
|
||||
}
|
||||
|
||||
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, {
|
||||
@@ -144,37 +249,92 @@ export class ClearanceWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
resolvePhase(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
if (cycle?.currentPhase) {
|
||||
return cycle.currentPhase as 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.inferPhase(contract, cycle, milestones);
|
||||
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 =
|
||||
contract.tradeDirection === 'IMPORT'
|
||||
tradeDirection === 'IMPORT'
|
||||
? isDone(IMPORT_DOC_UPLOADED)
|
||||
: isDone(EXPORT_DOC_UPLOADED);
|
||||
|
||||
if (!docUploaded) return ContractDocPhase.CustomerIntake;
|
||||
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
||||
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
if (cycle?.roHoldReason) return ContractDocPhase.GlDjCollection;
|
||||
return ContractDocPhase.GlDjCollection;
|
||||
}
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
@@ -182,12 +342,12 @@ export class ClearanceWorkflowService {
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
|
||||
// Import
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (cycle?.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
|
||||
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;
|
||||
}
|
||||
@@ -197,12 +357,42 @@ export class ClearanceWorkflowService {
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ClearanceNextAction | null {
|
||||
if (cycle?.roHoldReason) {
|
||||
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: cycle.roHoldReason,
|
||||
blockedReason: meta.roHoldReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,7 +407,7 @@ export class ClearanceWorkflowService {
|
||||
};
|
||||
|
||||
const docCode =
|
||||
contract.tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||
|
||||
if (pending(docCode) || !isDone(docCode)) {
|
||||
return {
|
||||
@@ -235,7 +425,12 @@ export class ClearanceWorkflowService {
|
||||
};
|
||||
}
|
||||
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
const terminalAction =
|
||||
terminalScope === 'contract'
|
||||
? 'Create shipment booking'
|
||||
: 'Proceed to request operation';
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
@@ -258,13 +453,12 @@ export class ClearanceWorkflowService {
|
||||
};
|
||||
}
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Create shipment booking',
|
||||
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||
action: terminalAction,
|
||||
milestoneCode: EXPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
// Import
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
@@ -273,7 +467,7 @@ export class ClearanceWorkflowService {
|
||||
};
|
||||
}
|
||||
|
||||
if (cycle?.dutyRequired === null || cycle?.dutyRequired === undefined) {
|
||||
if (meta.dutyRequired === null || meta.dutyRequired === undefined) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Set whether duty/tax applies',
|
||||
@@ -281,7 +475,7 @@ export class ClearanceWorkflowService {
|
||||
};
|
||||
}
|
||||
|
||||
if (cycle.dutyRequired && !isDone('DUTY_TAX_PAID')) {
|
||||
if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) {
|
||||
if (!isDone('DUTY_TAXES_ADVISED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
@@ -304,6 +498,14 @@ export class ClearanceWorkflowService {
|
||||
};
|
||||
}
|
||||
|
||||
if (!meta.preClearanceFinalizedAt) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Finalize pre-clearance',
|
||||
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone(IMPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
@@ -313,19 +515,17 @@ export class ClearanceWorkflowService {
|
||||
}
|
||||
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Create shipment booking',
|
||||
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||
action: terminalAction,
|
||||
milestoneCode: IMPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
/** Contracts where the next pending milestone is owned by ET. */
|
||||
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
|
||||
return pending?.milestoneCode ?? null;
|
||||
}
|
||||
|
||||
/** Contracts where the next pending milestone is owned by DJ. */
|
||||
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
|
||||
return pending?.milestoneCode ?? null;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ContractDocPhase } from '@edr/types';
|
||||
|
||||
/** Shared clearance metadata for contract cycles and per-booking GENERAL clearance. */
|
||||
export interface ClearanceMetaState {
|
||||
dutyRequired?: boolean | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
roHoldReason?: string | null;
|
||||
currentPhase?: ContractDocPhase | string | null;
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}
|
||||
|
||||
export type ClearanceScope =
|
||||
| { kind: 'contract'; contractId: string }
|
||||
| { kind: 'booking'; bookingId: string };
|
||||
|
||||
export function metaFromBooking(booking: {
|
||||
dutyRequired?: boolean | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
roHoldReason?: string | null;
|
||||
clearanceCurrentPhase?: string | null;
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}): ClearanceMetaState {
|
||||
return {
|
||||
dutyRequired: booking.dutyRequired ?? null,
|
||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt ?? null,
|
||||
roHoldReason: booking.roHoldReason ?? null,
|
||||
currentPhase: booking.clearanceCurrentPhase ?? null,
|
||||
preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? null,
|
||||
};
|
||||
}
|
||||
@@ -196,9 +196,11 @@ export class ContractBookingService {
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed post-booking milestones on the booking (no
|
||||
// cycle needed) and leave the contract active. The booking now drives its
|
||||
// own clearance via the booking-level pipeline.
|
||||
// Per-booking clearance: seed full milestone timeline on the booking.
|
||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
|
||||
@@ -9,10 +9,12 @@ import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||
import { contractClearanceCodes } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { assertDeclarationFiles, buildWorkflowFiles } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
@@ -63,6 +65,14 @@ export interface ContractClearanceView {
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
bookingReady?: 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()
|
||||
@@ -77,18 +87,49 @@ export class ContractClearanceService {
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(contract: Contract): boolean {
|
||||
return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME';
|
||||
}
|
||||
|
||||
private assertPhasedCustoms(contract: Contract): void {
|
||||
if (!contract.customsClearingEnabled) {
|
||||
throw new BadRequestException('Phased clearance applies only to customs contracts.');
|
||||
if (!this.isPhasedCustoms(contract)) {
|
||||
throw new BadRequestException(
|
||||
'Phased clearance (Phase 1) applies to one-time customs contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.contractKind !== 'ONE_TIME') {
|
||||
throw new BadRequestException('Phased clearance (Phase 1) applies to one-time contracts.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing
|
||||
* phased milestones. Revert that state so declaration / DO steps can proceed.
|
||||
*/
|
||||
private async reconcilePrematureBookingReady(
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
bookingReady: boolean,
|
||||
): Promise<Contract> {
|
||||
if (
|
||||
!this.isPhasedCustoms(contract) ||
|
||||
contract.status !== 'CLEARANCE_READY_FOR_BOOKING' ||
|
||||
bookingReady
|
||||
) {
|
||||
return contract;
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
let contract = await this.contractsService.findById(contractId);
|
||||
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
|
||||
@@ -151,12 +192,20 @@ export class ContractClearanceService {
|
||||
|
||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||
const milestones = await this.workflowService.listMilestones(contractId);
|
||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||
const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
const boundary = await this.workflowService.isBoundaryComplete(
|
||||
let boundary = await this.workflowService.isBoundaryComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||
const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
const documentFileKeys = new Set(documents.map((d) => d.fileKey));
|
||||
const workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
documentFileKeys,
|
||||
);
|
||||
|
||||
return {
|
||||
contractId,
|
||||
@@ -187,6 +236,34 @@ export class ContractClearanceService {
|
||||
? cycle.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
bookingReady: boundary,
|
||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
};
|
||||
}
|
||||
|
||||
private buildDutyAdvice(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractClearanceView['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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -462,6 +539,12 @@ export class ContractClearanceService {
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||
if (this.isPhasedCustoms(contract) && cycle.preClearanceFinalizedAt) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
preClearanceFinalizedAt: null,
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (status === 'APPROVED') {
|
||||
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
||||
@@ -514,8 +597,10 @@ export class ContractClearanceService {
|
||||
|
||||
/**
|
||||
* GL ET finalizes Path B pre-booking clearance: requires every customer
|
||||
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
|
||||
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
|
||||
* document APPROVED. For phased customs (ONE_TIME), document review completes
|
||||
* here — booking readiness is set only after delivery order (import) or export
|
||||
* release via the milestone workflow. Non-phased customs still jump straight to
|
||||
* CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts.
|
||||
*/
|
||||
async finalize(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
@@ -533,6 +618,24 @@ export class ContractClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isPhasedCustoms(contract)) {
|
||||
await this.workflowService.onAllDocsApproved(contractId);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase:
|
||||
contract.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (outputCode) {
|
||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
@@ -661,6 +764,24 @@ export class ContractClearanceService {
|
||||
|
||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||
|
||||
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
|
||||
private async ensureDeclarationPrerequisites(
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
): Promise<void> {
|
||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||
if (!allApproved) {
|
||||
throw new BadRequestException(
|
||||
'All required customer documents must be approved before uploading a declaration.',
|
||||
);
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestones(contractId);
|
||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
await this.workflowService.onAllDocsApproved(contractId);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadDeclaration(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
@@ -668,15 +789,17 @@ export class ContractClearanceService {
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
await this.ensureDeclarationPrerequisites(contractId, contract);
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
'DECLARED',
|
||||
'UNDER_CUSTOMS_CLEARANCE',
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
assertDeclarationFiles(files, contract.tradeDirection);
|
||||
|
||||
for (const file of files) {
|
||||
await this.filesService.upsertByCode({
|
||||
@@ -706,6 +829,7 @@ export class ContractClearanceService {
|
||||
contractId: string,
|
||||
dto: AdviseContractDutyDto,
|
||||
userId?: string,
|
||||
attachment?: Express.Multer.File,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
@@ -730,6 +854,15 @@ export class ContractClearanceService {
|
||||
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: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'duty_tax_notice',
|
||||
file: attachment,
|
||||
});
|
||||
await this.milestoneService.adviseDutyForContract(
|
||||
contractId,
|
||||
{
|
||||
@@ -808,13 +941,40 @@ export class ContractClearanceService {
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async finalizePreClearance(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Pre-clearance finalize applies only to import contracts.');
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
if (cycle.preClearanceFinalizedAt) {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
});
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async uploadDeliveryOrder(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
@@ -825,6 +985,14 @@ export class ContractClearanceService {
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.preClearanceFinalizedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
@@ -172,15 +172,11 @@ export class ContractTransitionService {
|
||||
const cargoTypeId =
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
||||
|
||||
// US-06 routing: bulk always needs director approval; container needs it only
|
||||
// when its cargo type flags it. Resolve the chain via the same approval_rules
|
||||
// source of truth the booking flow uses (no booking row is created here).
|
||||
let requiresDirectorApproval = contract.freightType === 'BULK';
|
||||
// Resolve the chain from the cargo type flag only.
|
||||
let requiresDirectorApproval = false;
|
||||
if (cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
||||
if (cargoType?.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false;
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
||||
|
||||
@@ -43,11 +43,13 @@ import { ContractsService } from './contracts.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
@@ -92,6 +94,8 @@ export class ContractsController {
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly bookingRequestService: BookingRequestService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
|
||||
@@ -486,7 +490,10 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
])
|
||||
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
|
||||
reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -536,13 +543,39 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/clearance/duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount' })
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' })
|
||||
adviseContractDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AdviseContractDutyDto,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.adviseDuty(id, dto, resolveAuthUserId(user));
|
||||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||||
const dto: AdviseContractDutyDto = {
|
||||
dutyRequired,
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
};
|
||||
return this.clearanceService.adviseDuty(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
attachment,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance — unlocks Djibouti DO upload' })
|
||||
finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.finalizePreClearance(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@@ -846,11 +879,16 @@ export class ContractsController {
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
|
||||
uploadDutySlip(
|
||||
async uploadDutySlip(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]);
|
||||
const file = (files ?? [])[0];
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
|
||||
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
|
||||
}
|
||||
return this.glOperationsService.uploadDutySlip(bookingId, file);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/incidents')
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
@@ -87,6 +88,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
BookingClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlOperationsService,
|
||||
@@ -106,6 +108,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
BookingClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
],
|
||||
|
||||
@@ -547,6 +547,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
| 'status'
|
||||
| 'preClearanceFinalizedAt'
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -51,4 +51,8 @@ export class ContractClearanceCycle extends BaseEntity {
|
||||
|
||||
@Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
currentPhase?: string | null;
|
||||
|
||||
/** ET GL confirms import pre-clearance complete — unlocks Djibouti DO upload. */
|
||||
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
catalogEntriesForTradeDirection,
|
||||
type ClearanceWorkflowFile,
|
||||
} from '@edr/types';
|
||||
|
||||
const IMPORT_DECLARATION_CODES = new Set(['im4', 'im5']);
|
||||
const EXPORT_DECLARATION_CODES = new Set(['ex3', 'ex8']);
|
||||
|
||||
/** Require at least one declaration file for the trade direction (IM4 or IM5, EX3 or EX8). */
|
||||
export function assertDeclarationFiles(
|
||||
files: Express.Multer.File[],
|
||||
tradeDirection: string,
|
||||
): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
|
||||
const allowed =
|
||||
tradeDirection === 'EXPORT' ? EXPORT_DECLARATION_CODES : IMPORT_DECLARATION_CODES;
|
||||
const labels = tradeDirection === 'EXPORT' ? 'EX3 or EX8' : 'IM4 or IM5';
|
||||
|
||||
const uploaded = new Set(files.map((f) => f.fieldname?.toLowerCase()));
|
||||
const hasValid = [...allowed].some((code) => uploaded.has(code));
|
||||
if (!hasValid) {
|
||||
throw new BadRequestException(`Upload at least one declaration document (${labels}).`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === undefined || value === '') return false;
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/** Build labeled phased-customs file rows from resource files. */
|
||||
export function buildWorkflowFiles(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
tradeDirection: string,
|
||||
documentFileKeys: Set<string> = new Set(),
|
||||
): ClearanceWorkflowFile[] {
|
||||
const fileByCode = new Map(
|
||||
files.filter((f) => f.code).map((f) => [f.code as string, f]),
|
||||
);
|
||||
const out: ClearanceWorkflowFile[] = [];
|
||||
|
||||
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
|
||||
if (documentFileKeys.has(entry.code)) continue;
|
||||
const file = fileByCode.get(entry.code) ?? null;
|
||||
if (!file) continue;
|
||||
out.push({
|
||||
code: entry.code,
|
||||
label: entry.label,
|
||||
uploadedBy: entry.uploadedBy,
|
||||
category: entry.category,
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -1,19 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { RouteStatus } from '../entities/route.entity';
|
||||
|
||||
export class CreateRouteMilestoneDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
distanceKm?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateRouteMilestoneDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@@ -21,8 +33,8 @@ export class CreateRouteDto {
|
||||
@Type(() => CreateRouteMilestoneDto)
|
||||
milestones!: CreateRouteMilestoneDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||
status?: RouteStatus;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { RouteStatus } from '../entities/route.entity';
|
||||
|
||||
export class FilterRoutesDto {
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||
status?: RouteStatus;
|
||||
}
|
||||
|
||||
@@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity {
|
||||
|
||||
@Column({ name: 'sequence_no', type: 'int' })
|
||||
sequenceNo!: number;
|
||||
|
||||
/** Kilometres from the previous stop (0 for origin). */
|
||||
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
distanceKm?: number | null;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { RouteMilestone } from './route-milestone.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'routes' })
|
||||
@Index(['name'])
|
||||
@Index(['isActive'])
|
||||
export class Route extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
|
||||
name!: string;
|
||||
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'routes' })
|
||||
@Index(['status'])
|
||||
export class Route extends BaseEntity {
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@@ -25,9 +23,24 @@ export class Route extends BaseEntity {
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
|
||||
status!: RouteStatus;
|
||||
|
||||
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
|
||||
export function formatRouteLabel(route: {
|
||||
originYard?: { code?: string; name?: string } | null;
|
||||
destinationYard?: { code?: string; name?: string } | null;
|
||||
}): string {
|
||||
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
|
||||
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
|
||||
return `${origin} → ${dest}`;
|
||||
}
|
||||
|
||||
export function totalRouteDistanceKm(
|
||||
milestones: Array<{ distanceKm?: number | string | null }>,
|
||||
): number {
|
||||
return milestones.reduce((sum, m) => sum + Number(m.distanceKm ?? 0), 0);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, ILike } from 'typeorm';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||
import { Route } from './entities/route.entity';
|
||||
import { formatRouteLabel, Route } from './entities/route.entity';
|
||||
import { RoutesRepository } from './routes.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,11 +16,10 @@ export class RoutesService {
|
||||
private readonly routesRepository: RoutesRepository,
|
||||
) {}
|
||||
|
||||
findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||
return this.routesRepository.findAll({
|
||||
async findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||
const routes = await this.routesRepository.findAll({
|
||||
where: {
|
||||
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
|
||||
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
},
|
||||
relations: {
|
||||
originYard: true,
|
||||
@@ -28,10 +27,33 @@ export class RoutesService {
|
||||
milestones: { yard: true },
|
||||
},
|
||||
order: {
|
||||
name: 'ASC',
|
||||
milestones: { sequenceNo: 'ASC' },
|
||||
},
|
||||
});
|
||||
|
||||
const sorted = [...routes].sort((a, b) =>
|
||||
formatRouteLabel(a).localeCompare(formatRouteLabel(b)),
|
||||
);
|
||||
|
||||
const query = filter.search?.trim().toLowerCase();
|
||||
if (!query) return sorted;
|
||||
|
||||
return sorted.filter((route) => {
|
||||
const haystack = [
|
||||
formatRouteLabel(route),
|
||||
route.originYard?.label,
|
||||
route.originYard?.code,
|
||||
route.destinationYard?.label,
|
||||
route.destinationYard?.code,
|
||||
...(route.milestones ?? []).map(
|
||||
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Route> {
|
||||
@@ -53,16 +75,14 @@ export class RoutesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateRouteDto): Promise<Route> {
|
||||
await this.validateRouteName(dto.name);
|
||||
const validated = await this.validateMilestones(dto.milestones);
|
||||
|
||||
const route = await this.dataSource.transaction(async (manager) => {
|
||||
const savedRoute = await manager.getRepository(Route).save(
|
||||
manager.getRepository(Route).create({
|
||||
name: dto.name.trim(),
|
||||
originYardId: validated.originYardId,
|
||||
destinationYardId: validated.destinationYardId,
|
||||
isActive: dto.isActive ?? true,
|
||||
status: dto.status ?? 'AVAILABLE',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -72,6 +92,7 @@ export class RoutesService {
|
||||
routeId: savedRoute.id,
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: milestone.sequenceNo,
|
||||
distanceKm: milestone.distanceKm,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -85,20 +106,16 @@ export class RoutesService {
|
||||
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.name && dto.name.trim() !== existing.name) {
|
||||
await this.validateRouteName(dto.name, id);
|
||||
}
|
||||
|
||||
const milestoneInput = dto.milestones
|
||||
? await this.validateMilestones(dto.milestones)
|
||||
: null;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Route).update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||
isActive: dto.isActive ?? existing.isActive,
|
||||
destinationYardId:
|
||||
milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
});
|
||||
|
||||
if (milestoneInput) {
|
||||
@@ -109,6 +126,7 @@ export class RoutesService {
|
||||
routeId: id,
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: milestone.sequenceNo,
|
||||
distanceKm: milestone.distanceKm,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -120,7 +138,9 @@ export class RoutesService {
|
||||
|
||||
async deactivate(id: string): Promise<Route> {
|
||||
await this.findById(id);
|
||||
const updated = await this.routesRepository.update(id, { isActive: false });
|
||||
const updated = await this.routesRepository.update(id, {
|
||||
status: 'STOP_WORKING',
|
||||
} as never);
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Route ${id} not found`);
|
||||
@@ -129,27 +149,32 @@ export class RoutesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async validateRouteName(name: string, routeId?: string) {
|
||||
const trimmedName = name.trim();
|
||||
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } });
|
||||
|
||||
if (existing && existing.id !== routeId) {
|
||||
throw new ConflictException(`Route name ${trimmedName} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||
private async validateMilestones(
|
||||
milestones: Array<{ yardId: string; distanceKm?: number }>,
|
||||
) {
|
||||
if (milestones.length < 2) {
|
||||
throw new BadRequestException('A route requires at least two yards');
|
||||
}
|
||||
|
||||
const normalized = milestones.map((milestone, index) => ({
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
}));
|
||||
const normalized = milestones.map((milestone, index) => {
|
||||
const distanceKm =
|
||||
index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
|
||||
if (index > 0 && (distanceKm == null || distanceKm < 0)) {
|
||||
throw new BadRequestException(
|
||||
`Enter segment KM for stop ${index + 1} (from previous yard).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
distanceKm,
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
|
||||
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||
const yardIds = new Set(yards.map((yard) => yard.id));
|
||||
|
||||
for (const milestone of normalized) {
|
||||
|
||||
@@ -21,11 +21,6 @@ export class CreateCargoTypeDto {
|
||||
@IsUUID()
|
||||
parentGroupId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showFreeTextBox?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -17,9 +17,6 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'parent_group_id', type: 'uuid', nullable: true })
|
||||
parentGroupId?: string | null;
|
||||
|
||||
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
|
||||
showFreeTextBox!: boolean;
|
||||
|
||||
/**
|
||||
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
|
||||
* (break-bulk). Nullable for container/legacy cargo, which is counted by
|
||||
|
||||
@@ -331,16 +331,14 @@ export class RuleEngineService {
|
||||
): Promise<BookingApprovalStep[]> {
|
||||
await this.ensureDefaultApprovalRules();
|
||||
|
||||
let requiresDirectorApproval = options.freightType === 'BULK';
|
||||
let requiresDirectorApproval = false;
|
||||
|
||||
if (options.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
||||
}
|
||||
if (cargoType.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
requiresDirectorApproval = cargoType.requiresDirectorApproval;
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||
|
||||
@@ -79,7 +79,6 @@ export class CargoTypesService {
|
||||
code,
|
||||
cargoTypeName: dto.cargoTypeName,
|
||||
parentGroupId: dto.parentGroupId ?? null,
|
||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DataSource } from 'typeorm';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
@@ -549,7 +550,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
@@ -624,7 +625,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { Route } from '../routes/entities/route.entity';
|
||||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
@@ -304,7 +304,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||
const route = await this.getActiveRoute(dto.routeId);
|
||||
const route = await this.getSchedulableRoute(dto.routeId);
|
||||
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
@@ -949,7 +949,7 @@ export class TrainSchedulingService {
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
route: schedule.route?.name ?? null,
|
||||
route: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
@@ -2605,13 +2605,17 @@ export class TrainSchedulingService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async getActiveRoute(routeId: string) {
|
||||
private async getSchedulableRoute(routeId: string) {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id: routeId },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
});
|
||||
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
||||
if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`);
|
||||
if (route.status !== 'AVAILABLE') {
|
||||
throw new BadRequestException(
|
||||
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
|
||||
);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -2656,7 +2660,7 @@ export class TrainSchedulingService {
|
||||
id: schedule.id,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
routeName: schedule.route?.name ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
@@ -2691,7 +2695,7 @@ export class TrainSchedulingService {
|
||||
|
||||
/** AVAILABLE locomotives at the route's origin yard. */
|
||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||
const route = await this.getActiveRoute(routeId);
|
||||
const route = await this.getSchedulableRoute(routeId);
|
||||
|
||||
const locomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
||||
@@ -2933,7 +2937,9 @@ export class TrainSchedulingService {
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null,
|
||||
route: schedule.route
|
||||
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
|
||||
: null,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||
actualDepartureAt: schedule.actualDepartureAt ?? null,
|
||||
|
||||
Reference in New Issue
Block a user