This commit is contained in:
marshal
2026-07-01 20:55:17 +03:00
parent 612df8daff
commit 9c18d086d7
112 changed files with 5654 additions and 1370 deletions

View File

@@ -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,
}),
);

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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);

View File

@@ -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' })

View File

@@ -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,

View File

@@ -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[];

View File

@@ -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;
}

View File

@@ -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;