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:
Marshal
2026-07-21 23:02:06 +00:00
parent 00a81fda15
commit 835c9e111c
35 changed files with 1896 additions and 154 deletions

View File

@@ -163,11 +163,12 @@ describe('BookingPricingService — domestic corridor', () => {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number }> }>;
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(0);
expect(result.blocked).toHaveLength(1);
});
it('does not price containers off a rate configured for a different leg', async () => {
@@ -197,4 +198,45 @@ describe('BookingPricingService — domestic corridor', () => {
expect(result.lineItems).toHaveLength(0);
});
// A mixed booking where only one container size has a configured rate must
// hard-block, not silently carry the unconfigured size for free.
it('blocks the unconfigured container size and prices the configured one', async () => {
const fortyOnly: Rate = {
...intercityContainerUsd,
id: 'rate-ct-40-only',
containerTypeId: 'ct-40',
} as Rate;
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
const booking = {
id: 'b-5',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [
{ containerTypeId: 'ct-40', quantity: 2 },
{ containerTypeId: 'ct-20', quantity: 3 },
],
});
expect(result.lineItems).toHaveLength(1);
expect(result.blocked).toHaveLength(1);
expect(result.blocked[0]).toContain('rate is configured');
});
});

View File

@@ -137,8 +137,12 @@ export class BookingPricingService {
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } =
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
const {
lineItems: baseLines,
usedRates: baseRates,
warnings: baseWarnings,
blocked: baseBlocked,
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
@@ -248,7 +252,7 @@ export class BookingPricingService {
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: [...ruleResult.warnings, ...baseWarnings],
hardBlocked: ruleResult.hardBlocked,
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
overweightLines,
};
}
@@ -454,7 +458,12 @@ export class BookingPricingService {
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> {
): Promise<{
lineItems: PriceLineItemDto[];
usedRates: Rate[];
warnings: string[];
blocked: string[];
}> {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
@@ -477,6 +486,7 @@ export class BookingPricingService {
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
@@ -500,11 +510,14 @@ export class BookingPricingService {
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
// Never price this line off another container type's (or another
// route's) rate an unpriced line with a warning is recoverable; a
// silently mischarged one is not.
warnings.push(
// route's) rate, and never let an unpriced line through: a booking
// that ships a container type nobody configured a rate for would be
// carried for free. Hard-block instead — the customer drops the line
// or EDR configures the rate.
blocked.push(
`No ${rateType} rate is configured for ${label} on this route — ` +
'the line was not priced.',
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
'to configure its rate for this origin → destination.',
);
continue;
}
@@ -587,10 +600,18 @@ export class BookingPricingService {
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
currency: paymentCurrency,
});
} else if (isBulk) {
// Same rule as container lines: bulk freight with no rate on this leg
// must not proceed unpriced.
blocked.push(
`No ${rateType} rate is configured for this route — the booking ` +
'cannot be priced. Ask EDR to configure the rate for this ' +
'origin → destination.',
);
}
}
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings };
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
}
/**