update import gl flow

This commit is contained in:
Marshal
2026-07-02 13:24:39 +00:00
parent 03740ee719
commit fe40d9f4be
8 changed files with 280 additions and 29 deletions

View File

@@ -135,7 +135,7 @@ export class BookingInvoiceService {
);
return;
}
if (booking.paymentStatus === "PAID") return;
// if (booking.paymentStatus === "PAID") return;
await this.dataSource.transaction(async (mg) => {
await mg.update(

View File

@@ -272,7 +272,7 @@ export class Booking extends BaseEntity {
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;

View File

@@ -65,6 +65,16 @@ function makeService(overrides?: {
children: [{ value: '2' }],
}),
};
const glOperationsService = {
t1State: jest.fn().mockResolvedValue({
bookingId: 'b-general',
wagonAllocated: false,
trainDepartedAt: null,
trainArrivedAt: null,
closed: false,
closedAt: null,
}),
};
const service = new BookingClearanceService(
bookingsRepository as never,
@@ -74,6 +84,7 @@ function makeService(overrides?: {
workflowService as never,
milestoneService as never,
dropdownSettingsService as never,
glOperationsService as never,
);
return {

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -11,6 +11,7 @@ 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 { GlOperationsService } from './gl-operations.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -63,6 +64,8 @@ export interface BookingClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until wagon allocation). */
t1?: ClearanceT1State | null;
}
@Injectable()
@@ -75,6 +78,7 @@ export class BookingClearanceService {
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
@@ -162,6 +166,15 @@ export class BookingClearanceService {
booking.tradeDirection ?? 'IMPORT',
);
let t1: ClearanceT1State | null = null;
if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') {
try {
t1 = await this.glOperationsService.t1State(bookingId);
} catch {
t1 = null;
}
}
return {
bookingId,
status: booking.status,
@@ -192,6 +205,7 @@ export class BookingClearanceService {
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
dutyAdvice,
workflowFiles,
t1,
};
}
@@ -421,6 +435,13 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
if (files.some((f) => f.code === 'delivery_order')) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
await this.workflowService.markReadyForOperation(bookingId);
}
return this.bookingsService.findById(bookingId);
}
@@ -434,15 +455,11 @@ export class BookingClearanceService {
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');
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and operation readiness) still
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
@@ -450,8 +467,10 @@ export class BookingClearanceService {
file,
});
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForOperation(bookingId);
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForOperation(bookingId);
}
return this.bookingsService.findById(bookingId);
}

View File

@@ -1,5 +1,5 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -10,6 +10,7 @@ import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
@@ -77,6 +78,8 @@ export interface ContractClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until a booking is linked). */
t1?: ClearanceT1State | null;
}
@Injectable()
@@ -90,6 +93,7 @@ export class ContractClearanceService {
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -224,6 +228,15 @@ export class ContractClearanceService {
workflowFiles = [...byCode.values()];
}
let t1: ClearanceT1State | null = null;
if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') {
try {
t1 = await this.glOperationsService.t1State(cycle.bookingId);
} catch {
t1 = null; // linked booking missing — view stays usable
}
}
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
@@ -272,6 +285,7 @@ export class ContractClearanceService {
linkedBookingId: cycle?.bookingId ?? null,
dutyAdvice,
workflowFiles,
t1,
};
}
@@ -1011,6 +1025,13 @@ export class ContractClearanceService {
currentPhase: ContractDocPhase.GlDjCollection,
});
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(contractId, 'contracts');
if (files.some((f) => f.code === 'delivery_order')) {
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
await this.workflowService.markReadyForBooking(contractId);
}
return this.contractsService.findById(contractId);
}
@@ -1025,17 +1046,11 @@ export class ContractClearanceService {
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');
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
@@ -1043,8 +1058,11 @@ export class ContractClearanceService {
file,
});
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForBooking(contractId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle?.preClearanceFinalizedAt) {
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForBooking(contractId);
}
return this.contractsService.findById(contractId);
}

View File

@@ -873,6 +873,33 @@ export class ContractsController {
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
}
@Post('bookings/:bookingId/t1-documents')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
})
uploadT1Documents(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
}
@Post('bookings/:bookingId/t1-close')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives',
})
closeT1(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
}
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -1,14 +1,19 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { isT1TransportFileCode, type Freight } from '@edr/types';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import {
ClearanceIncident,
IncidentType,
} from './entities/clearance-incident.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { persistExportTransportUploads } from './phased-clearance.util';
import {
persistExportTransportUploads,
persistT1TransportUploads,
} from './phased-clearance.util';
/**
* Maps a GL post-booking document `code` to the milestone it auto-completes when
@@ -18,7 +23,8 @@ import { persistExportTransportUploads } from './phased-clearance.util';
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
delivery_order: 'DO_COLLECTED', // import — GL DJ
t1_transport_document: 'T1_CLOSED', // import — GL ET
// t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only
// when GL Ethiopia accepts the T1 set after the train arrives (closeT1).
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
full_in_interchange: 'OFFLOADED', // export — GL DJ
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
@@ -161,6 +167,114 @@ export class GlOperationsService {
return { uploaded: files.length, completedMilestones };
}
/**
* T1 transit-document lifecycle state for an import shipment booking. Wagon
* allocation opens the upload window; train departure locks it; train arrival
* lets GL Ethiopia close (accept) the T1 set.
*/
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
const booking = await this.getBooking(bookingId);
const milestones = await this.milestoneService.listForBooking(bookingId);
const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
const wagonAllocated =
wagonMilestone?.status === 'COMPLETED' ||
booking.schedulingStatus === 'SCHEDULED' ||
booking.schedulingStatus === 'DISPATCHED' ||
Boolean(booking.trainScheduleId);
let schedule: TrainSchedule | null = null;
if (booking.trainScheduleId) {
schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: booking.trainScheduleId } });
}
const closedMilestone = milestones.find(
(m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
);
return {
bookingId,
wagonAllocated,
trainDepartedAt: schedule?.actualDepartureAt
? new Date(schedule.actualDepartureAt).toISOString()
: null,
trainArrivedAt: schedule?.actualArrivalAt
? new Date(schedule.actualArrivalAt).toISOString()
: null,
closed: Boolean(closedMilestone),
closedAt: closedMilestone?.triggeredAt
? new Date(closedMilestone.triggeredAt).toISOString()
: null,
};
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation.
* Replaces the previous batch; locked once the train departs or T1 is closed.
*/
async uploadT1Documents(
bookingId: string,
files: Express.Multer.File[],
): Promise<{ uploaded: number }> {
const booking = await this.getBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('T1 transport documents apply to import shipments only.');
}
const state = await this.t1State(bookingId);
if (!state.wagonAllocated) {
throw new BadRequestException(
'Wagons must be allocated before T1 transport documents can be uploaded.',
);
}
if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
}
if (state.trainDepartedAt) {
throw new BadRequestException(
'The train has departed — T1 transport documents can no longer be changed.',
);
}
await persistT1TransportUploads(this.filesService, bookingId, files);
return { uploaded: files.length };
}
/**
* GL Ethiopia closes (accepts) the T1 document set once the train has arrived.
* Completes the T1_CLOSED milestone; the document set becomes final.
*/
async closeT1(
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceT1State> {
const booking = await this.getBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('T1 closure applies to import shipments only.');
}
const state = await this.t1State(bookingId);
if (state.closed) return state;
if (!state.trainArrivedAt) {
throw new BadRequestException(
'The train has not arrived yet — T1 can be closed only after arrival.',
);
}
const files = await this.filesService.findByResource(bookingId, 'bookings');
const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
if (!hasT1) {
throw new BadRequestException(
'No T1 transport documents on file — GL Djibouti must upload them first.',
);
}
await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
return this.t1State(bookingId);
}
/**
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
*/

View File

@@ -5,7 +5,9 @@ import {
isDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
isT1TransportFileCode,
exportTransportFileLabel,
t1TransportFileLabel,
transitPermitFileLabel,
type ClearanceWorkflowFile,
} from '@edr/types';
@@ -160,6 +162,50 @@ export async function persistExportTransportUploads(
);
}
/** Require at least one T1 transport document in the upload batch. */
export function assertT1TransportFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No T1 transport documents uploaded');
}
}
export function normalizeT1TransportFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `t1_transport_document_${index}`,
}));
}
/** Replace all T1 transport documents on a booking with a new multi-file batch. */
export async function persistT1TransportUploads(
store: DeclarationFileStore,
bookingId: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeT1TransportFieldNames(files);
assertT1TransportFiles(normalized);
const existing = await store.findByResource(bookingId, 'bookings');
await Promise.all(
existing
.filter((f) => f.code && isT1TransportFileCode(f.code))
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId: bookingId,
resource: 'bookings',
code: `t1_transport_document_${index}`,
file,
}),
),
);
}
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
if (typeof value === 'boolean') return value;
if (value === undefined || value === '') return false;
@@ -194,9 +240,9 @@ export function belongsOnDjClearanceQueue(
);
if (hasDjActivity) return true;
const preFinalized =
cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
if (tradeDirection === 'IMPORT' && preFinalized) return true;
// Import DO upload is un-gated — Djibouti GL must see import customs items from
// the start, not only after Ethiopia finalizes pre-clearance.
if (tradeDirection === 'IMPORT') return true;
return false;
}
@@ -295,6 +341,22 @@ export function buildWorkflowFiles(
file: { id: file.id, name: file.name, url: file.url },
});
});
const extraT1 = files
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraT1.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: t1TransportFileLabel(file.code, index),
uploadedBy: 'gl_dj',
category: 'djibouti',
file: { id: file.id, name: file.name, url: file.url },
});
});
}
if (tradeDirection === 'EXPORT') {