Merge pull request #799 from Tria-plc/freight_feature/usermanagement

add per-container handling options for hazardous, reefer, and return…
This commit is contained in:
marshal
2026-07-18 22:22:46 +03:00
committed by GitHub
28 changed files with 585 additions and 250 deletions

View File

@@ -0,0 +1,28 @@
import { deriveRateType } from './rate-type.util';
describe('deriveRateType — surcharge triggers', () => {
// Every surcharge trigger must land on its own rateType. A trigger with no
// mapping falls through to the base-freight branch and is silently stored as
// CANCELLATION_FEE, which both mislabels the booking's rate snapshot and
// hides the rate from contract pricing (which looks rateTypes up by name).
it.each([
['HAZARDOUS', 'HAZARD_SURCHARGE'],
['REEFER', 'REEFER_SURCHARGE'],
['WITH_RETURN', 'RETURN_SURCHARGE'],
['OVERWEIGHT', 'OVERWEIGHT_PER_TON'],
['SHIPPING_LINE', 'DOUBLE_HANDLING'],
['CONSOLIDATION', 'LASHING'],
['CANCELLATION', 'CANCELLATION_FEE'],
['DEMURRAGE', 'DEMURRAGE'],
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
] as const)('maps trigger %s to %s', (trigger, expected) => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
});
it('does not fall back to CANCELLATION_FEE for the empty-return service', () => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger: 'WITH_RETURN' })).not.toBe(
'CANCELLATION_FEE',
);
});
});

View File

@@ -25,6 +25,12 @@ export function deriveRateType(input: {
return 'HAZARD_SURCHARGE';
case 'REEFER':
return 'REEFER_SURCHARGE';
// Empty-container return service. Contract pricing looks this rateType up
// by name, so without the mapping a WITH_RETURN rate fell through to the
// base-freight branch and was stored as CANCELLATION_FEE — invisible to
// the contract, and mislabelled on the booking's snapshot.
case 'WITH_RETURN':
return 'RETURN_SURCHARGE';
case 'OVERWEIGHT':
return 'OVERWEIGHT_PER_TON';
case 'SHIPPING_LINE':

View File

@@ -42,6 +42,14 @@ export interface BookingContainerEvalInput {
isReefer?: boolean;
isOverweight?: boolean;
overweightExcessTons?: number | null;
/**
* How many individual containers on this line opted into each handling
* service. PER_CONTAINER surcharges bill these counts, not the line
* quantity — 20 containers with 10 hazardous bill hazard on 10.
*/
hazardousQuantity?: number;
reeferQuantity?: number;
returnQuantity?: number;
}
export interface BookingEvaluationInput {
@@ -270,6 +278,27 @@ export class RuleEngineService {
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
/**
* Containers that opted into this trigger's handling service, summed
* across lines. null when the trigger isn't per-container handling (or
* no line carries a count) so the caller falls back to the full count.
*/
const optedInCount = (trigger: string | null): number | null => {
const field =
trigger === 'HAZARDOUS'
? 'hazardousQuantity'
: trigger === 'REEFER'
? 'reeferQuantity'
: trigger === 'WITH_RETURN'
? 'returnQuantity'
: null;
if (!field) return null;
const total = input.containers.reduce(
(sum, c) => sum + Number(c[field] ?? 0),
0,
);
return total > 0 ? total : null;
};
let triggerValue: number | null = null;
let calculatedAmount: number;
@@ -285,7 +314,11 @@ export class RuleEngineService {
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_CONTAINER':
triggerValue = containerCount;
// Handling surcharges bill only the containers that opted in, not the
// whole line — 20 containers with 10 hazardous bill hazard on 10.
// Legacy bookings carry no per-container counts (all 0) while their
// booking-level flag is set, so fall back to the full count there.
triggerValue = optedInCount(rate.trigger) ?? containerCount;
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_WAGON':