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

@@ -320,6 +320,12 @@ export class ContractBookingService {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
// a positive total, so the zero-price gate below misses it — enforce
// the pricing hard blocks first. The catch below rolls everything back.
if (computed.hardBlocked.length > 0) {
throw new BadRequestException(computed.hardBlocked.join('; '));
}
// Reject a zero-price booking outright. A total of 0 means no contract rate
// matched the route/container (or the rate is unset), so the booking is not
// valid to ship or invoice. The catch below rolls back the row + its lines.
@@ -744,15 +750,20 @@ export class ContractBookingService {
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// A zero price means no contract rate matches — roll the cargo back so
// the instance stays CLEARANCE_READY and can be completed again once
// the contract rates are fixed (the clearance work is not lost).
if (!(computed.totalAmount > 0)) {
// the contract rates are fixed (the clearance work is not lost). A
// pricing hard block (e.g. one of two container sizes has no rate)
// rolls back the same way: a partially-priced total is positive but
// the booking must not proceed.
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
throw new BadRequestException(
'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
computed.hardBlocked.length > 0
? computed.hardBlocked.join('; ')
: 'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
);
}
await this.bookingsRepository.update(booking.id, {
@@ -1896,7 +1907,10 @@ export class ContractBookingService {
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
capacityErrors: [...scopeErrors, ...capacityErrors],
// Pricing hard blocks (missing rate for a container size / requested
// service) ride the capacity-errors channel so the form hard-blocks in
// the preview instead of failing at the create call.
capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
containerClashErrors,
spaceErrors,
lineItems: computed.lineItems,

View File

@@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
'OTHER',
] as const;
export type IncidentType = (typeof INCIDENT_TYPES)[number];