feat: add clearance review section and update booking status handling

- Introduced `ClearanceReviewSection` component for document review and approval process.
- Updated booking status configuration to include new clearance statuses.
- Modified `DocumentClearanceDetailPage` and `BookingRequestDetailPage` to integrate the new clearance review functionality.
- Adjusted `DocumentClearanceListPage` to filter customs bookings appropriately.
- Enhanced `ClearanceCard` in the portal to reflect customs clearance status.
- Removed unnecessary customs-related logic from clearance tabs and document review components.
This commit is contained in:
Marshal
2026-06-25 01:50:21 +00:00
parent 23b353999a
commit da01b4d453
14 changed files with 762 additions and 538 deletions

View File

@@ -63,7 +63,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
);
});
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
it('moves to CLEARANCE_READY when all required documents are APPROVED (non-customs, no output set)', async () => {
const { service, bookingsRepository } = makeService([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
@@ -75,3 +75,74 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
);
});
});
/**
* Customs bookings additionally require the GL output documents before
* finalizing — they are cleared by Global Logistics, not the customer alone.
*/
describe('BookingTransitionService — finalizeClearance customs output gate', () => {
const customsBooking = {
id: 'b-2',
status: 'DOCUMENTS_UNDER_REVIEW',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: { includesCustoms: true }, // input + output sets apply
};
const inputSetting = {
code: 'clearance_import_container_with_customs',
fields: [{ fileKey: 'commercial_invoice', isRequired: true }],
};
const outputSetting = {
code: 'clearance_output_import_container',
fields: [{ fileKey: 'im4', fileLabel: 'IM4 declaration', isRequired: true }],
};
function makeCustomsService(uploadedOutputCodes: string[]) {
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
]),
update: jest.fn().mockResolvedValue({ id: 'b-2' }),
};
const bookingsService = { findById: jest.fn().mockResolvedValue(customsBooking) };
const fileUploadSettingsService = {
getByCode: jest.fn((code: string) =>
Promise.resolve(code === outputSetting.code ? outputSetting : inputSetting),
),
};
const filesService = {
findByResource: jest
.fn()
.mockResolvedValue(uploadedOutputCodes.map((code) => ({ code }))),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
filesService as never,
fileUploadSettingsService as never,
{} as never,
bookingsService as never,
);
return { service, bookingsRepository };
}
it('rejects when required customs output documents are missing', async () => {
const { service } = makeCustomsService([]); // no output uploaded
await expect(service.finalizeClearance('b-2')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('moves to CLEARANCE_READY when input is approved and output docs are present', async () => {
const { service, bookingsRepository } = makeCustomsService(['im4']);
await service.finalizeClearance('b-2');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-2',
expect.objectContaining({ status: 'CLEARANCE_READY' }),
);
});
});

View File

@@ -39,6 +39,7 @@ export interface BookingListFilterOptions {
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
consolidationPaired?: string;
@@ -728,6 +729,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
excludePaymentStatus: options.excludePaymentStatus,
});
}
if (options.customsClearingEnabled !== undefined) {
qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', {
customsClearingEnabled: options.customsClearingEnabled,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {

View File

@@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
@@ -119,6 +120,18 @@ export class BookingsService {
}
/** Build evaluation input from booking freight shape. */
/**
* Whether a service type bundles customs clearance. This is the single source
* of truth for a booking's `customsClearingEnabled` — the customer cannot
* diverge from it, and it decides who clears the documents (GL vs Marketing).
*/
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
return serviceType?.includesCustoms ?? false;
}
private async buildEvalInput(dto: {
freightType: FreightType;
cargoTypeId?: string | null;
@@ -404,6 +417,11 @@ export class BookingsService {
warnings.push(...ruleResult.warnings);
// Customs clearing is owned by the service type, not the customer: when the
// service includes customs, EDR/GL clears it (no external agent); otherwise
// the customer clears it themselves and may name their broker.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
@@ -421,8 +439,8 @@ export class BookingsService {
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
customsClearingEnabled: dto.customsClearingEnabled ?? false,
customsClearingAgent: dto.customsClearingAgent ?? null,
customsClearingEnabled: includesCustoms,
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
@@ -652,6 +670,16 @@ export class BookingsService {
if (dto.endDate) updates.endDate = new Date(dto.endDate);
delete updates.containers;
// Customs clearing always mirrors the (possibly changed) service type — never
// the client payload — so it can't diverge from the service's customs scope.
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
await this.bookingsRepository.update(id, updates);
if (freightType === 'CONTAINER' && dto.containers) {
@@ -820,6 +848,9 @@ export class BookingsService {
page,
pageSize,
statuses,
// Global Logistics only clears customs bookings; non-customs clearance is
// reviewed by Marketing from the booking detail, not this queue.
customsClearingEnabled: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});