finilize gl

This commit is contained in:
marshal
2026-07-02 12:06:17 +03:00
parent 9c18d086d7
commit 4fefe4f827
50 changed files with 5150 additions and 1793 deletions

View File

@@ -699,6 +699,7 @@ export class BookingTransitionService {
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
} as never);
@@ -763,6 +764,15 @@ export class BookingTransitionService {
if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document');
}
if (
status === 'QUERIED' &&
this.isPhasedGeneralCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
await this.bookingsRepository.setDocumentReviewStatus(
bookingId,
@@ -779,10 +789,10 @@ export class BookingTransitionService {
'CHANGES_REQUESTED',
staffId,
);
if (this.isPhasedGeneralCustoms(booking) && booking.preClearanceFinalizedAt) {
if (this.isPhasedGeneralCustoms(booking)) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
preClearanceFinalizedAt: null,
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
} as never);
}
}

View File

@@ -543,16 +543,16 @@ export class BookingsController {
@Post(':id/clearance/transit-permit')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
async uploadBookingTransitPermit(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadTransitPermit(
id,
file,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);

View File

@@ -12,7 +12,7 @@ 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';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -157,11 +157,9 @@ export class BookingClearanceService {
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 {
@@ -279,16 +277,8 @@ export class BookingClearanceService {
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 persistDeclarationUploads(this.filesService, bookingId, 'bookings', files);
await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId);
await this.bookingsRepository.update(bookingId, {
@@ -380,7 +370,7 @@ export class BookingClearanceService {
async uploadTransitPermit(
bookingId: string,
file: Express.Multer.File,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
@@ -392,14 +382,11 @@ export class BookingClearanceService {
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
if (!file) throw new BadRequestException('No transit permit uploaded');
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'transit_permitted',
file,
});
await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files);
await this.workflowService.completeMilestoneForBooking(
bookingId,
@@ -598,32 +585,31 @@ export class BookingClearanceService {
async etQueue(): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
const next = this.workflowService.computeNextActionForBooking(b, milestones);
if (next?.actor === 'GL_ET') filtered.push(b);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
return filtered;
}
async djQueue(): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
...DJ_BOOKING_QUEUE_STATUSES,
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
const pending = this.workflowService.djPendingMilestoneCodes(milestones);
if (b.roHoldReason || pending || this.workflowService.computeNextActionForBooking(b, milestones)?.actor === 'GL_DJ') {
if (
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
roHoldReason: b.roHoldReason,
preClearanceFinalizedAt: b.preClearanceFinalizedAt,
})
) {
filtered.push(b);
}
}

View File

@@ -197,6 +197,26 @@ export class ClearanceMilestoneService {
}
/** Skip optional milestones (e.g. duty when not required). */
/** Reopen a completed contract milestone so review can continue after a query. */
async reopenForContract(contractId: string, code: string): Promise<void> {
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
if (!milestone || milestone.status !== 'COMPLETED') return;
milestone.status = 'PENDING';
milestone.triggeredAt = null;
milestone.triggeredByUserId = null;
await this.repo.save(milestone);
}
/** Reopen a completed booking milestone so review can continue after a query. */
async reopenForBooking(bookingId: string, code: string): Promise<void> {
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
if (!milestone || milestone.status !== 'COMPLETED') return;
milestone.status = 'PENDING';
milestone.triggeredAt = null;
milestone.triggeredByUserId = null;
await this.repo.save(milestone);
}
async skipForContract(contractId: string, code: string): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
if (!milestone) {

View File

@@ -204,6 +204,15 @@ export class ClearanceWorkflowService {
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
}
/** Customer doc queried or re-uploaded — document approval milestone must reopen. */
async onDocumentReviewReopened(contractId: string): Promise<void> {
await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED');
}
async onDocumentReviewReopenedForBooking(bookingId: string): Promise<void> {
await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED');
}
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
await this.completeMilestone(contractId, 'DECLARED', userId);
@@ -441,7 +450,7 @@ export class ClearanceWorkflowService {
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
action: 'Upload customs declaration (EX3/EX8)',
action: 'Upload customs declaration documents',
milestoneCode: 'DECLARED',
};
}
@@ -452,6 +461,30 @@ export class ClearanceWorkflowService {
milestoneCode: EXPORT_BOUNDARY,
};
}
if (terminalScope === 'booking') {
if (!isDone('FREIGHT_PAYMENT_SETTLED')) {
return {
actor: 'CUSTOMER',
action: 'Pay freight charges',
milestoneCode: 'FREIGHT_PAYMENT_SETTLED',
};
}
if (!isDone('WAGON_ALLOCATED')) {
return {
actor: 'OPERATIONS',
action: 'Allocate wagon',
milestoneCode: 'WAGON_ALLOCATED',
};
}
if (!isDone('EXPORT_TRANSPORT_ISSUED')) {
return {
actor: 'GL_ET',
action: 'Upload transit permit',
milestoneCode: 'EXPORT_TRANSPORT_ISSUED',
};
}
return null;
}
return {
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
action: terminalAction,
@@ -462,7 +495,7 @@ export class ClearanceWorkflowService {
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
action: 'Upload customs declaration (IM4/IM5)',
action: 'Upload customs declaration documents',
milestoneCode: 'DECLARED',
};
}

View File

@@ -6,6 +6,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
import { FilesService } from '../files/files.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService, PaginatedContracts } from './contracts.service';
import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
@@ -14,7 +15,7 @@ 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';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -66,6 +67,9 @@ export interface ContractClearanceView {
roAmendmentRequestedAt?: string | null;
bookingReady?: boolean;
preClearanceFinalized?: boolean;
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
exportClearanceFinalized?: boolean;
linkedBookingId?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -80,6 +84,7 @@ export class ContractClearanceService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService,
private readonly bookingsService: BookingsService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly workflowService: ClearanceWorkflowService,
@@ -198,14 +203,40 @@ export class ContractClearanceService {
);
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(
let workflowFiles = buildWorkflowFiles(
files,
contract.tradeDirection ?? 'IMPORT',
documentFileKeys,
);
if (cycle?.bookingId) {
const bookingFiles = await this.filesService.findByResource(
cycle.bookingId,
'bookings',
);
const bookingWorkflow = buildWorkflowFiles(
bookingFiles,
contract.tradeDirection ?? 'IMPORT',
);
const byCode = new Map(workflowFiles.map((f) => [f.code, f]));
for (const row of bookingWorkflow) {
if (row.file) byCode.set(row.code, row);
}
workflowFiles = [...byCode.values()];
}
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
}
return {
contractId,
@@ -237,6 +268,8 @@ export class ContractClearanceService {
: null,
bookingReady: boundary,
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
exportClearanceFinalized: Boolean(cycle?.completedAt),
linkedBookingId: cycle?.bookingId ?? null,
dutyAdvice,
workflowFiles,
};
@@ -413,6 +446,7 @@ export class ContractClearanceService {
if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
await this.workflowService.onDocumentReviewReopened(contractId);
}
return this.contractsService.findById(contractId);
@@ -505,6 +539,15 @@ export class ContractClearanceService {
const { inputCode, outputCode } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (
status === 'QUERIED' &&
this.isPhasedCustoms(contract) &&
cycle?.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
@@ -539,10 +582,12 @@ export class ContractClearanceService {
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
if (this.isPhasedCustoms(contract) && cycle.preClearanceFinalizedAt) {
}
if (this.isPhasedCustoms(contract)) {
await this.workflowService.onDocumentReviewReopened(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
preClearanceFinalizedAt: null,
currentPhase: ContractDocPhase.GlEtPostClearance,
currentPhase: ContractDocPhase.GlEtReview,
});
}
}
@@ -704,19 +749,14 @@ export class ContractClearanceService {
}
/**
* GL ET clearance hub: every customs (Path B) contract that still needs
* customs clearance — awaiting the customer's documents, under GL review, or
* finalized and waiting for the customer to create the booking in the portal.
* GL ET clearance hub: every customs (Path B) contract in phased clearance,
* including after booking is created.
*/
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
],
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
@@ -799,16 +839,13 @@ export class ContractClearanceService {
if (files.length === 0) {
throw new BadRequestException('No declaration documents uploaded');
}
assertDeclarationFiles(files, contract.tradeDirection);
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: file.fieldname,
file,
});
}
await persistDeclarationUploads(
this.filesService,
contractId,
'contracts',
files,
);
await this.workflowService.onDeclarationUploaded(contractId, userId);
@@ -913,7 +950,7 @@ export class ContractClearanceService {
async uploadTransitPermit(
contractId: string,
file: Express.Multer.File,
files: Express.Multer.File[],
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
@@ -927,14 +964,16 @@ export class ContractClearanceService {
'TRANSIT_PERMIT_UPLOADED',
);
if (!file) throw new BadRequestException('No transit permit uploaded');
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'transit_permitted',
file,
});
await persistTransitPermitUploads(
this.filesService,
contractId,
'contracts',
files,
);
await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);
@@ -1135,12 +1174,51 @@ export class ContractClearanceService {
return this.contractsService.findById(contractId);
}
/** GL ET queue: customs ONE_TIME contracts with a pending ET-owned milestone. */
/** GL ET finalizes export clearance after post-booking transit permit is uploaded. */
async finalizeExportClearance(contractId: string, userId?: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Export clearance finalize applies only to export contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.bookingId) {
throw new BadRequestException(
'A shipment booking must exist before export clearance can be finalized.',
);
}
if (cycle.completedAt) {
return this.contractsService.findById(contractId);
}
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
const transportDone = bookingMilestones.some(
(m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED',
);
if (!transportDone) {
throw new BadRequestException(
'Upload the transit permit before finalizing export clearance.',
);
}
await this.contractsRepository.updateCycle(cycle.id, {
completedAt: new Date(),
currentPhase: ContractDocPhase.GlEtPostClearance,
});
void userId;
return this.contractsService.findById(contractId);
}
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'],
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
@@ -1150,13 +1228,7 @@ export class ContractClearanceService {
const filtered: typeof base.items = [];
for (const c of base.items) {
const milestones = await this.workflowService.listMilestones(c.id);
const pending = this.workflowService.etPendingMilestoneCodes(milestones);
const next = this.workflowService.computeNextAction(
c,
await this.contractsRepository.currentCycle(c.id),
milestones,
);
if (pending || next?.actor === 'GL_ET') filtered.push(c);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
}
const page = filter.page ?? 1;
@@ -1178,12 +1250,12 @@ export class ContractClearanceService {
};
}
/** GL DJ queue: customs ONE_TIME contracts with a pending DJ-owned milestone or RO hold. */
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'],
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
@@ -1194,9 +1266,9 @@ export class ContractClearanceService {
for (const c of base.items) {
const cycle = await this.contractsRepository.currentCycle(c.id);
const milestones = await this.workflowService.listMilestones(c.id);
const pending = this.workflowService.djPendingMilestoneCodes(milestones);
const next = this.workflowService.computeNextAction(c, cycle, milestones);
if (cycle?.roHoldReason || pending || next?.actor === 'GL_DJ') filtered.push(c);
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
filtered.push(c);
}
}
const page = filter.page ?? 1;

View File

@@ -532,7 +532,7 @@ export class ContractsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET uploads customs declaration (IM4/IM5 or EX3/EX8)' })
@ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' })
uploadDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@@ -591,15 +591,15 @@ export class ContractsController {
@Post(':id/clearance/transit-permit')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET uploads transit permit screenshot (import)' })
@ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' })
uploadTransitPermit(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadTransitPermit(id, file, resolveAuthUserId(user));
return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user));
}
@Post(':id/clearance/delivery-order')
@@ -655,16 +655,28 @@ export class ContractsController {
return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user));
}
@Post(':id/clearance/finalize-export-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary: 'GL ET finalizes export clearance after post-booking transit permit upload',
})
finalizeExportClearance(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
}
@Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL Ethiopia phased clearance queue' })
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
etClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.etQueue(filter);
}
@Get('clearance/dj-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL Djibouti phased clearance queue' })
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
djClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.djQueue(filter);
}

View File

@@ -548,6 +548,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
| 'currentPhase'
| 'status'
| 'preClearanceFinalizedAt'
| 'completedAt'
>
>,
): Promise<void> {

View File

@@ -0,0 +1,125 @@
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util';
describe('buildWorkflowFiles', () => {
const resourceFiles = [
{ code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' },
{ code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' },
{
code: 'transit_permitted',
id: 'f-transit',
name: 'transit.png',
url: '/files/transit',
},
{
code: 'duty_tax_notice',
id: 'f-duty',
name: 'notice.pdf',
url: '/files/duty',
},
{ code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' },
];
it('includes declaration and transit files even when they also appear in GL output document settings', () => {
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
expect(result.map((f) => f.code)).toEqual(
expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']),
);
});
it('includes multi-file declaration uploads alongside catalog codes', () => {
const result = buildWorkflowFiles(
[
...resourceFiles,
{
code: 'declaration_0',
id: 'f-dec-0',
name: 'decl-a.pdf',
url: '/files/decl-a',
},
{
code: 'declaration_1',
id: 'f-dec-1',
name: 'decl-b.pdf',
url: '/files/decl-b',
},
],
'IMPORT',
);
expect(result.map((f) => f.code)).toEqual(
expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']),
);
expect(result.find((f) => f.code === 'declaration_0')?.label).toBe(
'Declaration document 1',
);
});
it('includes multi-file import transit permit uploads', () => {
const result = buildWorkflowFiles(
[
...resourceFiles,
{
code: 'transit_permit_0',
id: 'f-tp-0',
name: 'permit-a.pdf',
url: '/files/tp-a',
},
{
code: 'transit_permit_1',
id: 'f-tp-1',
name: 'permit-b.pdf',
url: '/files/tp-b',
},
],
'IMPORT',
);
expect(result.map((f) => f.code)).toEqual(
expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']),
);
expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1');
});
it('does not include non-catalog customer document codes', () => {
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false);
});
});
describe('belongsOnDjClearanceQueue', () => {
it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => {
expect(
belongsOnDjClearanceQueue(
'IMPORT',
{ preClearanceFinalizedAt: new Date('2026-01-01') },
[],
),
).toBe(true);
});
it('keeps contracts with completed Djibouti milestones', () => {
expect(
belongsOnDjClearanceQueue('IMPORT', null, [
{ ownerRegion: 'DJ', status: 'COMPLETED' },
]),
).toBe(true);
});
it('excludes import contracts still on Ethiopia-side clearance only', () => {
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
});
});
describe('belongsOnEtClearanceQueue', () => {
it('keeps contracts once phased clearance milestones exist', () => {
expect(
belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]),
).toBe(true);
});
it('excludes contracts with no clearance milestones', () => {
expect(belongsOnEtClearanceQueue([])).toBe(false);
});
});

View File

@@ -1,53 +1,213 @@
import { BadRequestException } from '@nestjs/common';
import {
catalogEntriesForTradeDirection,
declarationFileLabel,
isDeclarationFileCode,
isImportTransitPermitFileCode,
transitPermitFileLabel,
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 {
/** Require at least one declaration file in the upload batch. */
export function assertDeclarationFiles(files: Express.Multer.File[]): 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';
/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */
export function normalizeDeclarationFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `declaration_${index}`,
}));
}
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}).`);
type DeclarationFileStore = {
findByResource(
resourceId: string,
resource: string,
): Promise<Array<{ code?: string | null }>>;
deleteByCode(resourceId: string, resource: string, code: string): Promise<void>;
upload(input: {
resourceId: string;
resource: string;
code: string;
file: Express.Multer.File;
}): Promise<unknown>;
};
/** Replace all declaration files on a resource with a new multi-file upload batch. */
export async function persistDeclarationUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeDeclarationFieldNames(files);
assertDeclarationFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isDeclarationFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `declaration_${index}`,
file,
}),
),
);
}
/** Require at least one transit permit file in the upload batch. */
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
}
/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */
export function normalizeTransitPermitFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `transit_permit_${index}`,
}));
}
/** Replace all import transit permit files on a resource with a new multi-file batch. */
export async function persistTransitPermitUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeTransitPermitFieldNames(files);
assertTransitPermitFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isImportTransitPermitFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `transit_permit_${index}`,
file,
}),
),
);
}
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';
}
type DjQueueMilestone = {
ownerRegion?: string | null;
status: string;
};
type DjQueueCycle = {
preClearanceFinalizedAt?: Date | null;
roHoldReason?: string | null;
} | null | undefined;
/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */
export function belongsOnDjClearanceQueue(
tradeDirection: string | null | undefined,
cycle: DjQueueCycle,
milestones: DjQueueMilestone[],
extras?: {
roHoldReason?: string | null;
preClearanceFinalizedAt?: Date | null;
},
): boolean {
const roHold = cycle?.roHoldReason ?? extras?.roHoldReason;
if (roHold) return true;
const hasDjActivity = milestones.some(
(m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'),
);
if (hasDjActivity) return true;
const preFinalized =
cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
if (tradeDirection === 'IMPORT' && preFinalized) return true;
return false;
}
/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */
export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
] as const;
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean {
return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED');
}
/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */
export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES;
/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */
export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
'FULLY_EXECUTED',
'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED',
'ROAD_DISPATCH_PENDING',
'IN_TRANSIT',
'PAID',
'COMPLETED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
] as const;
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES;
/** 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[] = [];
const included = new Set<string>();
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
if (documentFileKeys.has(entry.code)) continue;
const file = fileByCode.get(entry.code) ?? null;
if (!file) continue;
included.add(entry.code);
out.push({
code: entry.code,
label: entry.label,
@@ -57,5 +217,39 @@ export function buildWorkflowFiles(
});
}
const extraDeclarations = files
.filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraDeclarations.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: declarationFileLabel(file.code, index),
uploadedBy: 'gl_et',
category: 'declaration',
file: { id: file.id, name: file.name, url: file.url },
});
});
if (tradeDirection === 'IMPORT') {
const extraTransit = files
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraTransit.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: transitPermitFileLabel(file.code, index),
uploadedBy: 'gl_et',
category: 'transit',
file: { id: file.id, name: file.name, url: file.url },
});
});
}
return out;
}

View File

@@ -61,6 +61,14 @@ export class FilesService {
return this.upload(input);
}
async deleteByCode(
resourceId: string,
resource: string,
code: string,
): Promise<void> {
await this.filesRepository.deleteByCode(resourceId, resource, code);
}
async uploadMany(
resourceId: string,
resource: string,

View File

@@ -219,13 +219,13 @@ export class PaymentService {
//////////////// fake
await this.datasource.manager.update(
Booking,
{ id: input.referenceId },
{ status: "PAID", paymentStatus: "PAID" },
);
await this.firstMileService.acceptBooking(input.referenceId);
await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId);
// await this.datasource.manager.update(
// Booking,
// { id: input.referenceId },
// { status: "PAID", paymentStatus: "PAID" },
// );
// await this.firstMileService.acceptBooking(input.referenceId);
// await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId);
//////////////// fake