enhance booking windows section with pagination and improved UI

This commit is contained in:
Marshal
2026-07-04 00:41:28 +00:00
parent 61f70d5471
commit 97cc9d76b1
21 changed files with 805 additions and 285 deletions

View File

@@ -8,13 +8,13 @@ import {
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
@@ -66,7 +66,6 @@ export class ContractBookingService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
private readonly exchangeService: ExchangeService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
@@ -587,11 +586,14 @@ export class ContractBookingService {
}
/**
* Pre-create validation for the shipment form: run the overweight rule + the
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
* booking. The portal calls this from the price-confirm modal so the customer
* sees the overweight warning (+ surcharge basis) and is blocked on an
* un-pairable 20ft set before the booking is created.
* Pre-create validation + authoritative price preview for the shipment form:
* build an UNSAVED booking shaped exactly like {@link createUnderContract}
* would persist it and run the same BookingPricingService compute over it —
* base rail freight, first/last-mile trucking, and every rule-engine surcharge
* (overweight, hazard, reefer, consolidation, …). The portal and the GL
* backoffice form call this from the price-confirm modal, so the breakdown the
* user confirms is line-for-line what the booking will be charged. Also runs
* the 20ft weight-pairing rule, which hard-blocks creation.
*/
async validateShipment(
contractId: string,
@@ -606,22 +608,26 @@ export class ContractBookingService {
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
lineItems: PriceLineItemDto[];
totalAmount: number;
}> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const lines = dto.containers ?? [];
if (!lines.length) {
if (contract.freightType === 'CONTAINER' && !lines.length) {
return {
overweightLines: [],
overweightSurchargeAmount: 0,
currency: null,
pairingErrors: [],
lineItems: [],
totalAmount: 0,
};
}
// Resolve each line's container type + total VGM (sum of unit weights) so the
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
// Resolve each container line's type + total VGM (sum of unit weights)
// mirrors persistContainers so the preview lines match the persisted ones.
const resolved = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
@@ -636,46 +642,44 @@ export class ContractBookingService {
}),
);
const ruleResult = await this.ruleEngineService.evaluate({
freightType: 'CONTAINER',
cargoTypeId: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
// The unsaved twin of the booking createUnderContract would write: same
// denormalized contract fields, same container-line math. No id → the
// pricing service derives wagon counts from the in-memory lines.
const route = await this.resolveRoute(contract, dto.contractRouteId);
const previewBooking = Object.assign(new Booking(), {
freightType: contract.freightType,
tradeDirection: contract.tradeDirection,
isHazardous: false,
isReefer: contract.isReefer ?? false,
isGovernment: false,
allowConsolidation: false,
paymentCurrency: contract.paymentCurrency,
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isGovernment: contract.isGovernment,
shippingLineId: null,
totalWagons: 0,
bulkTons: 0,
containers: resolved.map((r) => ({
containerTypeId: r.ct.id,
quantity: r.line.quantity,
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
totalVgmTons: r.totalVgmTons,
isReefer: r.ct.isReefer,
})),
} as never);
contractRouteId: route?.id ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
Object.assign(new BookingContainer(), {
containerTypeId: ct.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
}),
),
}) as Booking;
const overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}> = [];
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue;
const r = resolved[i];
const excessTons = Number(wr.overweightExcessTons ?? 0);
overweightLines.push({
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
totalVgmTons: r?.totalVgmTons ?? 0,
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
excessTons,
});
}
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
// The overweight surcharge line is already currency-converted; surface its
// amount separately so the warning alert can reference the exact charge.
const overweightSurchargeAmount =
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
const twentyFtUnits = resolved
@@ -691,27 +695,13 @@ export class ContractBookingService {
(v) => v.message,
);
// Real overweight surcharge (same rate the rule engine bills at booking-create
// time) so the confirm-modal total isn't missing the charge the warning refers to.
// Rates are stored in USD; convert to the contract's payment currency the same
// way BookingPricingService does so this preview matches the eventual booking total.
const overweightModifier = ruleResult.appliedModifiers.find(
(m) => m.surchargeCode === 'OVERWEIGHT_PER_TON',
);
let overweightSurchargeAmount = 0;
if (overweightModifier) {
const isEtb = contract.paymentCurrency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
overweightSurchargeAmount = isEtb
? Math.round(overweightModifier.calculatedAmount * usdToEtb)
: overweightModifier.calculatedAmount;
}
return {
overweightLines,
overweightLines: computed.overweightLines,
overweightSurchargeAmount,
currency: overweightLines.length ? contract.paymentCurrency : null,
currency: computed.currency,
pairingErrors,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
};
}

View File

@@ -791,7 +791,7 @@ export class ContractsController {
@Post(':id/validate-shipment')
@ApiOperation({
summary:
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
})
validateShipment(
@Param('id', ParseUUIDPipe) id: string,