mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Added ContainerValidationService to handle 20ft weight-pairing logic. - Introduced validate20ftWeightPairing utility function to check weight differences. - Updated BookingPricingService to include overweight line details and pairing errors in price response. - Enhanced BookingTransitionService to reject submissions with unpairable 20ft containers. - Created ShipmentValidation interface for pre-submit validation of container contracts. - Integrated shipment validation into the contract booking process, providing warnings for overweight containers and hard blocks for pairing errors. - Updated front-end components to display validation results and prevent submission when errors are present.
252 lines
9.1 KiB
TypeScript
252 lines
9.1 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { BookingTransitionService } from './booking-transition.service';
|
|
|
|
/**
|
|
* Focused tests for the clearance 100%-approved gate in finalizeClearance.
|
|
* Uses minimal stubs for the service's collaborators.
|
|
*/
|
|
describe('BookingTransitionService — finalizeClearance gate', () => {
|
|
const booking = {
|
|
id: 'b-1',
|
|
status: 'DOCUMENTS_UNDER_REVIEW',
|
|
tradeDirection: 'IMPORT',
|
|
freightType: 'CONTAINER',
|
|
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
|
};
|
|
|
|
// Input set has two required docs.
|
|
const inputSetting = {
|
|
code: 'clearance_import_container_without_customs',
|
|
fields: [
|
|
{ fileKey: 'commercial_invoice', isRequired: true },
|
|
{ fileKey: 'packing_list', isRequired: true },
|
|
],
|
|
};
|
|
|
|
function makeService(reviews: Array<{ settingCode: string; fileKey: string; status: string }>) {
|
|
const bookingsRepository = {
|
|
findDocumentReviews: jest.fn().mockResolvedValue(reviews),
|
|
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
|
};
|
|
const bookingsService = {
|
|
findById: jest.fn().mockResolvedValue(booking),
|
|
};
|
|
const fileUploadSettingsService = {
|
|
getByCode: jest.fn().mockResolvedValue(inputSetting),
|
|
};
|
|
const filesService = { findByResource: jest.fn().mockResolvedValue([]) };
|
|
|
|
const service = new BookingTransitionService(
|
|
bookingsRepository as never,
|
|
{} as never, // ruleEngineService
|
|
{} as never, // pricingService
|
|
{} as never, // contractService
|
|
{} as never, // invoiceService
|
|
filesService as never,
|
|
fileUploadSettingsService as never,
|
|
{} as never, // bookingBatchService
|
|
bookingsService as never,
|
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
|
{} as never,
|
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
|
);
|
|
return { service, bookingsRepository };
|
|
}
|
|
|
|
it('rejects when a required document is not APPROVED', async () => {
|
|
const { service } = makeService([
|
|
{
|
|
settingCode: inputSetting.code,
|
|
fileKey: 'commercial_invoice',
|
|
status: 'APPROVED',
|
|
},
|
|
// packing_list is still PENDING (missing approval)
|
|
]);
|
|
await expect(service.finalizeClearance('b-1')).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
});
|
|
|
|
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' },
|
|
]);
|
|
await service.finalizeClearance('b-1');
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-1',
|
|
expect.objectContaining({ status: 'CLEARANCE_READY' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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,
|
|
{} as never, // invoiceService
|
|
filesService as never,
|
|
fileUploadSettingsService as never,
|
|
{} as never,
|
|
bookingsService as never,
|
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
|
{} as never,
|
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } 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' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The first clearance submission (AWAITING_DOCUMENTS) must include every
|
|
* required input document; subsequent re-uploads during review only need the
|
|
* specific files being fixed, so already-uploaded required docs stay in place.
|
|
*/
|
|
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
|
const inputSetting = {
|
|
code: 'clearance_import_container_without_customs',
|
|
fields: [
|
|
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
|
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
|
],
|
|
};
|
|
|
|
function makeService(status: string, existingCodes: string[]) {
|
|
const bookingsRepository = {
|
|
upsertDocumentReviewPending: jest.fn().mockResolvedValue(undefined),
|
|
update: jest.fn().mockResolvedValue({ id: 'b-3' }),
|
|
};
|
|
const booking = {
|
|
id: 'b-3',
|
|
status,
|
|
tradeDirection: 'IMPORT',
|
|
freightType: 'CONTAINER',
|
|
serviceType: { includesCustoms: false },
|
|
};
|
|
const bookingsService = { findById: jest.fn().mockResolvedValue(booking) };
|
|
const fileUploadSettingsService = {
|
|
getByCode: jest.fn().mockResolvedValue(inputSetting),
|
|
};
|
|
const filesService = {
|
|
findByResource: jest
|
|
.fn()
|
|
.mockResolvedValue(existingCodes.map((code) => ({ code }))),
|
|
upsertByCode: jest.fn().mockResolvedValue({ id: 'file-rec' }),
|
|
};
|
|
|
|
const service = new BookingTransitionService(
|
|
bookingsRepository as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never, // invoiceService
|
|
filesService as never,
|
|
fileUploadSettingsService as never,
|
|
{} as never,
|
|
bookingsService as never,
|
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
|
{} as never,
|
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
|
);
|
|
return { service, bookingsRepository, filesService };
|
|
}
|
|
|
|
function fakeFile(fieldname: string): Express.Multer.File {
|
|
return { fieldname, originalname: `${fieldname}.pdf` } as Express.Multer.File;
|
|
}
|
|
|
|
it('rejects the first submission when a required document is missing', async () => {
|
|
const { service } = makeService('AWAITING_DOCUMENTS', []);
|
|
await expect(
|
|
service.submitClearanceDocuments('b-3', [fakeFile('commercial_invoice')]),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('accepts the first submission when every required document is provided', async () => {
|
|
const { service, bookingsRepository } = makeService('AWAITING_DOCUMENTS', []);
|
|
await service.submitClearanceDocuments('b-3', [
|
|
fakeFile('commercial_invoice'),
|
|
fakeFile('packing_list'),
|
|
]);
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-3',
|
|
expect.objectContaining({ status: 'DOCUMENTS_UNDER_REVIEW' }),
|
|
);
|
|
});
|
|
|
|
it('allows re-uploading a single queried document during review without re-sending the rest', async () => {
|
|
// packing_list was already uploaded in the first round; the customer is now
|
|
// only re-uploading the queried commercial_invoice.
|
|
const { service, bookingsRepository } = makeService(
|
|
'DOCUMENTS_UNDER_REVIEW',
|
|
['packing_list'],
|
|
);
|
|
await service.submitClearanceDocuments('b-3', [
|
|
fakeFile('commercial_invoice'),
|
|
]);
|
|
// Only the re-uploaded doc is touched — no full re-gate, no rework on the rest.
|
|
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledTimes(1);
|
|
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledWith(
|
|
expect.objectContaining({ fileKey: 'commercial_invoice' }),
|
|
);
|
|
});
|
|
});
|