mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
feat(train-scheduling): implement container movement between wagons
- Added functionality to move containers between wagons in the train scheduling system. - Introduced API endpoint and service method to handle container movement. - Updated component to support drag-and-drop for rearranging containers. - Enhanced to allow moving containers to other wagons via a context menu. - Implemented UI feedback for container movement actions, including loading states and success/error notifications. - Updated relevant types and constants to accommodate new container movement logic. - Added tests for the rule engine to ensure proper handling of hazardous bookings.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
import type { BookingEvaluationInput } from './rule-engine.service';
|
||||
import type { Rate } from './entities/rate.entity';
|
||||
|
||||
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||||
const hazardRate: Rate = {
|
||||
id: 'rate-hazard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let ratesRepo: { findLiveRates: jest.Mock };
|
||||
let service: RuleEngineService;
|
||||
|
||||
beforeEach(() => {
|
||||
ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||||
service = new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
|
||||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
|
||||
ratesRepo as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
|
||||
{} as never, // dataSource (unused by evaluate)
|
||||
);
|
||||
});
|
||||
|
||||
const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
containers: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
|
||||
const result = await service.evaluate(input({ isHazardous: true }));
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
|
||||
it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
|
||||
ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
|
||||
const result = await service.evaluate(input({ isHazardous: true }));
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
|
||||
const result = await service.evaluate(input({}));
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
|
||||
const result = await service.evaluate(
|
||||
input({
|
||||
containers: [
|
||||
{
|
||||
containerTypeId: 'ct-20',
|
||||
quantity: 2,
|
||||
vgmPerUnitTons: 10,
|
||||
totalVgmTons: 20,
|
||||
reeferQuantity: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('reefer');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
} from './interfaces/shipping-lines.repository.interface';
|
||||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||
|
||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||
|
||||
export interface BookingContainerEvalInput {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
@@ -245,6 +249,48 @@ export class RuleEngineService {
|
||||
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
||||
);
|
||||
|
||||
// A handling service the booking asks for (booking-level flag OR any
|
||||
// per-container opt-in count) with no LIVE surcharge rate configured is a
|
||||
// hard block — pricing would otherwise ship the service for free. System-
|
||||
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||||
// exempt: the customer never opted into those, so they must not block.
|
||||
const requestedServices: Array<{
|
||||
trigger: RateTrigger;
|
||||
wanted: boolean;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
trigger: 'HAZARDOUS',
|
||||
wanted:
|
||||
truthy(input.isHazardous) ||
|
||||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||||
label: 'hazardous cargo',
|
||||
},
|
||||
{
|
||||
trigger: 'REEFER',
|
||||
wanted:
|
||||
hasReefer ||
|
||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||
label: 'refrigerated (reefer) cargo',
|
||||
},
|
||||
{
|
||||
trigger: 'WITH_RETURN',
|
||||
wanted:
|
||||
truthy(input.withReturn) ||
|
||||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
|
||||
label: 'empty-container return',
|
||||
},
|
||||
];
|
||||
for (const svc of requestedServices) {
|
||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||
hardBlocked.push(
|
||||
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||||
`be priced with this service. Remove the ${svc.label} option or ` +
|
||||
'ask EDR to configure its rate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rate of surchargeRates) {
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
@@ -438,9 +484,6 @@ export class RuleEngineService {
|
||||
hasLashing: boolean;
|
||||
},
|
||||
): boolean {
|
||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||
switch (trigger) {
|
||||
case 'HAZARDOUS':
|
||||
return truthy(state.isHazardous);
|
||||
|
||||
Reference in New Issue
Block a user