mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 05:55:02 +00:00
- 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.
332 lines
9.6 KiB
TypeScript
332 lines
9.6 KiB
TypeScript
import { BadRequestException, ConflictException } from '@nestjs/common';
|
|
|
|
import { RatesService } from './rates.service';
|
|
import type { Rate } from '../entities/rate.entity';
|
|
|
|
/**
|
|
* One rate per lane + scope, whatever the unit.
|
|
*
|
|
* Pricing resolves a single rate for a (type, container type, leg) and then
|
|
* applies whatever unit it carries — it has no way to choose between a
|
|
* per-container and a per-wagon row for the same 20ft lane, and used to bill
|
|
* whichever the database happened to return first. So the unit is NOT part of a
|
|
* rate's identity: changing how a lane is billed means editing its rate.
|
|
*/
|
|
describe('RatesService — one rate per pattern', () => {
|
|
const DJ = '11111111-1111-4000-8000-000000000001';
|
|
const ET = '11111111-1111-4000-8000-000000000002';
|
|
const CT20 = '11111111-1111-4000-8000-000000000003';
|
|
|
|
const existing = (over: Partial<Rate> = {}): Rate =>
|
|
({
|
|
id: 'rate-existing',
|
|
rateType: 'CONTAINER_IMPORT',
|
|
rateUnit: 'PER_WAGON',
|
|
rateValue: 1690,
|
|
containerTypeId: CT20,
|
|
cargoTypeId: null,
|
|
tradeDirection: 'IMPORT',
|
|
originYardId: DJ,
|
|
destinationYardId: ET,
|
|
status: 'LIVE',
|
|
...over,
|
|
}) as Rate;
|
|
|
|
const dto = {
|
|
appliesTo: 'CONTAINER',
|
|
trigger: 'ALWAYS',
|
|
tradeDirection: 'IMPORT',
|
|
containerTypeId: CT20,
|
|
originYardId: DJ,
|
|
destinationYardId: ET,
|
|
rateValue: 845,
|
|
rateUnit: 'PER_CONTAINER',
|
|
};
|
|
|
|
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' : 'Gelan',
|
|
})),
|
|
} as never,
|
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
|
// Shipping line companies — these rates carry no owner, so it is never hit.
|
|
{ findById: jest.fn() } as never,
|
|
);
|
|
});
|
|
|
|
it('refuses a second rate on the same lane that only differs by unit', async () => {
|
|
repository.findByPattern.mockResolvedValue(existing());
|
|
|
|
await expect(service.create(dto as never, 'staff-1')).rejects.toBeInstanceOf(
|
|
ConflictException,
|
|
);
|
|
expect(repository.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('looks the pattern up without the unit, so either order collides', async () => {
|
|
await service.create(dto as never, 'staff-1');
|
|
|
|
const pattern = repository.findByPattern.mock.calls[0][0];
|
|
expect(pattern).not.toHaveProperty('rateUnit');
|
|
expect(pattern).toMatchObject({
|
|
rateType: 'CONTAINER_IMPORT',
|
|
containerTypeId: CT20,
|
|
originYardId: DJ,
|
|
destinationYardId: ET,
|
|
});
|
|
});
|
|
|
|
it('still allows the same unit on a different lane', async () => {
|
|
await service.create(dto as never, 'staff-1');
|
|
expect(repository.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
rateUnit: 'PER_CONTAINER',
|
|
rateValue: 845,
|
|
status: 'DRAFT',
|
|
}),
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Additive surcharges are billed per matching rate, each by its own unit, so
|
|
* 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(
|
|
{
|
|
appliesTo: 'OTHER',
|
|
trigger: 'HAZARDOUS',
|
|
rateValue: 300,
|
|
rateUnit: 'PER_TON',
|
|
} as never,
|
|
'staff-1',
|
|
);
|
|
|
|
expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({
|
|
rateType: 'HAZARD_SURCHARGE',
|
|
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(
|
|
{
|
|
appliesTo: 'OTHER',
|
|
trigger: 'LASHING',
|
|
tradeDirection: 'IMPORT',
|
|
rateValue: 40,
|
|
rateUnit: 'PER_TON',
|
|
} as never,
|
|
'staff-1',
|
|
);
|
|
|
|
expect(repository.findByPattern.mock.calls[0][0]).not.toHaveProperty(
|
|
'rateUnit',
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
});
|
|
|