feat: empty container Import

This commit is contained in:
hager
2026-09-04 22:43:20 +00:00
parent 2b6c78cf71
commit 3fb5cdf29f
36 changed files with 968 additions and 49 deletions

View File

@@ -27,3 +27,29 @@ describe('deriveRateType — surcharge triggers', () => {
);
});
});
describe('deriveRateType — empty container freight', () => {
it('splits empty freight from laden freight by direction', () => {
expect(deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS' })).toBe(
'EMPTY_CONTAINER_IMPORT',
);
expect(
deriveRateType({
appliesTo: 'EMPTY_CONTAINER',
trigger: 'ALWAYS',
tradeDirection: 'EXPORT',
}),
).toBe('EMPTY_CONTAINER_EXPORT');
});
// UQ_rates_pattern keys on rate_type but not on applies_to, so an empty rate
// sharing CONTAINER_IMPORT would collide with the laden rate for the same
// lane and container type. The distinct rateType is what keeps both fileable.
it('never resolves to the laden container rate type', () => {
for (const tradeDirection of ['IMPORT', 'EXPORT']) {
expect(
deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS', tradeDirection }),
).not.toBe(tradeDirection === 'EXPORT' ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT');
}
});
});

View File

@@ -58,6 +58,8 @@ export function deriveRateType(input: {
switch (appliesTo) {
case 'CONTAINER':
return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT';
case 'EMPTY_CONTAINER':
return isExport ? 'EMPTY_CONTAINER_EXPORT' : 'EMPTY_CONTAINER_IMPORT';
case 'BULK':
return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT';
case 'INTERCITY':

View File

@@ -84,3 +84,25 @@ describe("allowedRateUnits — bulk unit of measure", () => {
expect(isBulkQuantityUnit("FLAT")).toBe(false);
});
});
/**
* Empty equipment carries no cargo, so no weighed unit applies — only the box
* and the wagon it rides on.
*/
describe("allowedRateUnits — empty container freight", () => {
it("offers per-container and per-wagon only", () => {
expect(
allowedRateUnits({ appliesTo: "EMPTY_CONTAINER", trigger: "ALWAYS" }),
).toEqual(["PER_CONTAINER", "PER_WAGON"]);
});
it("never offers a weighed unit, even for a per-item commodity scope", () => {
expect(
allowedRateUnits({
appliesTo: "EMPTY_CONTAINER",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_ITEM",
}),
).not.toContain("PER_ITEM");
});
});

View File

@@ -98,6 +98,10 @@ function unitsForShape(input: {
switch (appliesTo) {
case 'CONTAINER':
return ['PER_CONTAINER', 'PER_WAGON'];
case 'EMPTY_CONTAINER':
// Empty equipment carries no cargo to weigh, so the only bases that mean
// anything are the box itself and the wagon it rides on.
return ['PER_CONTAINER', 'PER_WAGON'];
case 'BULK':
return ['PER_TON', 'PER_WAGON'];
case 'INTERCITY':

View File

@@ -8,6 +8,12 @@ import { Yard } from './yard.entity';
export const RATE_TYPES = [
'CONTAINER_IMPORT',
'CONTAINER_EXPORT',
// Empty equipment moved as freight in its own right — no cargo, priced per
// box by size. Distinct from CONTAINER_IMPORT because UQ_rates_pattern keys
// on rate_type: an empty 40ft Djibouti->Modjo rate filed as CONTAINER_IMPORT
// would collide with the laden 40ft rate for the same lane.
'EMPTY_CONTAINER_IMPORT',
'EMPTY_CONTAINER_EXPORT',
'BULK_IMPORT',
'BULK_EXPORT',
'INTERCITY_BULK',
@@ -59,12 +65,14 @@ export type RateUnit = typeof RATE_UNITS[number];
* lookup and snapshots).
*
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
* - EMPTY_CONTAINER : base rail freight for empty equipment
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
* - OTHER : trigger-based surcharges (hazard, reefer …)
*/
export const RATE_APPLIES_TO = [
'BULK',
'CONTAINER',
'EMPTY_CONTAINER',
'INTERCITY',
'FIRST_MILE',
'LAST_MILE',

View File

@@ -24,7 +24,12 @@ import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.reposito
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = [
'BULK',
'CONTAINER',
'EMPTY_CONTAINER',
'INTERCITY',
];
/**
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
@@ -381,6 +386,30 @@ export class RatesService {
return;
}
if (appliesTo === 'EMPTY_CONTAINER') {
// Northbound repositioning only. Southbound empties are already sold by
// the WITH_RETURN surcharge and empty_return_requests; a second path to
// the same movement would let the business double-sell it.
if (tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'An empty container rate is import-only for now.',
);
}
// Size is the entire scope of an empty rate — there is no cargo to narrow
// by, so the box type must be named and a commodity must not be.
if (!containerTypeId) {
throw new BadRequestException(
'An empty container rate must name the container type it covers.',
);
}
if (cargoTypeId) {
throw new BadRequestException(
'An empty container rate cannot be scoped to a bulk cargo type.',
);
}
return;
}
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,