feat: Implement container hazardous-cargo surcharge logic

- Added support for container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) in the rule engine.
- Introduced new method  in  to calculate and apply container hazard charges based on booking details.
- Updated  to handle per-container hazard rates, ensuring they are scoped by trade direction and lane.
- Enhanced tests to cover scenarios for container hazard rates, including validation for required fields and conflict checks.
- Created a migration to update existing rates and enforce new constraints for container hazard rates in the database.
This commit is contained in:
marshal
2026-09-06 22:28:57 +00:00
parent 1eb9f10354
commit 00c0d86a8a
11 changed files with 789 additions and 98 deletions

View File

@@ -1,4 +1,4 @@
import { ConflictException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { RatesService } from './rates.service';
import type { Rate } from '../entities/rate.entity';
@@ -101,8 +101,9 @@ describe('RatesService — one rate per pattern', () => {
/**
* Additive surcharges are billed per matching rate, each by its own unit, so
* hazard is legitimately per-container for boxes AND per-ton for bulk. The
* unit stays part of their identity or the second one could never be created.
* the bulk (per-ton) hazard rate coexists with the lane-sold container one.
* The unit stays part of their identity or the second one could never be
* created.
*/
it('keeps the unit in the key for an additive surcharge', async () => {
await service.create(
@@ -110,17 +111,44 @@ describe('RatesService — one rate per pattern', () => {
appliesTo: 'OTHER',
trigger: 'HAZARDOUS',
rateValue: 300,
rateUnit: 'PER_CONTAINER',
rateUnit: 'PER_TON',
} as never,
'staff-1',
);
expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({
rateType: 'HAZARD_SURCHARGE',
rateUnit: 'PER_CONTAINER',
rateUnit: 'PER_TON',
});
});
it('keeps the per-ton hazard rate global — direction and lane are dropped', async () => {
await service.create(
{
appliesTo: 'OTHER',
trigger: 'HAZARDOUS',
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: ET,
containerTypeId: CT20,
rateValue: 5,
rateUnit: 'PER_TON',
} as never,
'staff-1',
);
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
rateType: 'HAZARD_SURCHARGE',
rateUnit: 'PER_TON',
tradeDirection: null,
originYardId: null,
destinationYardId: null,
containerTypeId: null,
}),
);
});
it('treats lashing as singly resolved — one unit per direction', async () => {
await service.create(
{
@@ -138,3 +166,166 @@ describe('RatesService — one rate per pattern', () => {
);
});
});
/**
* The container hazard surcharge (HAZARDOUS per container) is sold per
* direction + lane, optionally per box size — the same shape as the
* empty-return service. Pricing resolves exactly one rate per lane + size, so
* the unit leaves the identity and the lane joins it.
*/
describe('RatesService — per-container hazard is sold per lane', () => {
const DJ = '11111111-1111-4000-8000-000000000001';
const ET = '11111111-1111-4000-8000-000000000002';
const ET2 = '11111111-1111-4000-8000-000000000004';
const CT20 = '11111111-1111-4000-8000-000000000003';
let repository: { findByPattern: jest.Mock; create: jest.Mock };
let service: RatesService;
beforeEach(() => {
repository = {
findByPattern: jest.fn().mockResolvedValue(null),
create: jest.fn(async (r) => ({ id: 'rate-new', ...r })),
};
service = new RatesService(
repository as never,
{
findById: jest.fn(async (id: string) => ({
id,
country: id === DJ ? 'Djibouti' : 'Ethiopia',
label: id === DJ ? 'Doraleh' : id === ET ? 'Gelan' : 'Dire Dawa',
})),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn() } as never,
);
});
const containerHazard = {
appliesTo: 'OTHER',
trigger: 'HAZARDOUS',
rateValue: 300,
rateUnit: 'PER_CONTAINER',
};
it('requires a trade direction', async () => {
await expect(
service.create(
{ ...containerHazard, originYardId: DJ, destinationYardId: ET } as never,
'staff-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.create).not.toHaveBeenCalled();
});
it('requires both yards of the lane', async () => {
await expect(
service.create(
{ ...containerHazard, tradeDirection: 'IMPORT' } as never,
'staff-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.create).not.toHaveBeenCalled();
});
it('rejects a lane that contradicts the direction', async () => {
// Export runs Ethiopia → Djibouti; this leg is the import shape.
await expect(
service.create(
{
...containerHazard,
tradeDirection: 'EXPORT',
originYardId: DJ,
destinationYardId: ET,
} as never,
'staff-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('files the rate per direction + lane + box size, unit out of the key', async () => {
await service.create(
{
...containerHazard,
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: ET,
containerTypeId: CT20,
} as never,
'staff-1',
);
const pattern = repository.findByPattern.mock.calls[0][0];
expect(pattern).not.toHaveProperty('rateUnit');
expect(pattern).toMatchObject({
rateType: 'HAZARD_SURCHARGE',
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: ET,
containerTypeId: CT20,
});
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
rateType: 'HAZARD_SURCHARGE',
rateUnit: 'PER_CONTAINER',
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: ET,
containerTypeId: CT20,
cargoTypeId: null,
}),
);
});
it('accepts a lane catch-all with no box size', async () => {
await service.create(
{
...containerHazard,
tradeDirection: 'EXPORT',
originYardId: ET,
destinationYardId: DJ,
} as never,
'staff-1',
);
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
tradeDirection: 'EXPORT',
originYardId: ET,
destinationYardId: DJ,
containerTypeId: null,
}),
);
});
it('accepts a DOMESTIC (intercity) lane inside Ethiopia', async () => {
await service.create(
{
...containerHazard,
tradeDirection: 'DOMESTIC',
originYardId: ET,
destinationYardId: ET2,
} as never,
'staff-1',
);
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({ tradeDirection: 'DOMESTIC', originYardId: ET, destinationYardId: ET2 }),
);
});
it('refuses a second rate for the same lane + box size', async () => {
repository.findByPattern.mockResolvedValue({ id: 'rate-existing' } as Rate);
await expect(
service.create(
{
...containerHazard,
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: ET,
containerTypeId: CT20,
} as never,
'staff-1',
),
).rejects.toBeInstanceOf(ConflictException);
});
});