feat: implement 20ft container weight-pairing validation

- Added ContainerValidationService to handle 20ft weight-pairing logic.
- Introduced validate20ftWeightPairing utility function to check weight differences.
- Updated BookingPricingService to include overweight line details and pairing errors in price response.
- Enhanced BookingTransitionService to reject submissions with unpairable 20ft containers.
- Created ShipmentValidation interface for pre-submit validation of container contracts.
- Integrated shipment validation into the contract booking process, providing warnings for overweight containers and hard blocks for pairing errors.
- Updated front-end components to display validation results and prevent submission when errors are present.
This commit is contained in:
Marshal
2026-07-03 09:26:27 +00:00
parent d832e83b4a
commit 35cb20da0b
20 changed files with 646 additions and 5 deletions

View File

@@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
@@ -51,6 +52,7 @@ export class BookingTransitionService {
@Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
) {}
@@ -58,6 +60,19 @@ export class BookingTransitionService {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
}
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
private async assert20ftPairable(booking: Booking): Promise<void> {
const violations =
await this.containerValidationService.validate20ftPairing(booking);
if (violations.length) {
throw new BadRequestException(
`Cannot submit — 20ft containers cannot be paired on wagons: ${violations
.map((v) => v.message)
.join(' ')}`,
);
}
}
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
@@ -78,6 +93,11 @@ export class BookingTransitionService {
requiresDirectorApproval: false,
});
// 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
// If no balanced pairing exists the booking cannot proceed (overweight only
// warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
await this.assert20ftPairable(booking);
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
@@ -158,6 +178,7 @@ export class BookingTransitionService {
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.assert20ftPairable(booking);
await this.pricingService.createPricingSnapshots(
bookingId,