mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
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:
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user