feat: full wagon cancel, leg board, wagon dates

feat(freight): editable train leg times, SL invoice payer
This commit is contained in:
Marshal
2026-08-15 10:12:37 +00:00
parent c9c2d4dcb3
commit dc38e843a6
31 changed files with 1619 additions and 217 deletions

View File

@@ -10,6 +10,8 @@ import { In, Repository } from "typeorm";
import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service";
import type { Container20ftUnit } from "../bookings/container-pairing.util";
import { ContainerValidationService } from "../bookings/container-validation.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
import { Booking } from "../bookings/entities/booking.entity";
@@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService {
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly creditsService: ShippingLineCreditsService,
private readonly containerValidationService: ContainerValidationService,
) {}
/**
* 20ft weight-pairing check over the completion payload — the same rule the
* customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t):
* two 20ft sharing a wagon must be within the cap. Preview surfaces the
* messages; completion hard-blocks on them. Runs off the DTO so nothing is
* persisted before the check passes.
*/
private async pairingViolationMessages(
dto: CompleteShippingLineBookingDto,
): Promise<string[]> {
const units: Container20ftUnit[] = [];
for (const line of dto.containers ?? []) {
const containerType = await this.resolveContainerType(line);
if (containerType.sizeFt !== 20) continue;
(line.units ?? []).forEach((u, idx) =>
units.push({
label: u.containerNumber || `20ft-${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
}),
);
}
const violations =
await this.containerValidationService.validate20ftPairingUnits(units);
return violations.map((v) => v.message);
}
/** Same session→owner resolution every shipping-line entry point uses. */
private async requireShippingLine(userId: string) {
const shippingLine =
@@ -193,6 +222,17 @@ export class ShippingLineBookingCompletionService {
);
}
// Unbalanced 20ft pairs can never be planned onto wagons — refuse before
// any cargo/credit write below. Same block the contract path applies.
if (booking.freightType === "CONTAINER") {
const pairing = await this.pairingViolationMessages(dto);
if (pairing.length) {
throw new BadRequestException(
`Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`,
);
}
}
// Completion is booking time. A lane with trains DEDICATED to this line
// has no window concept at all: the line books whenever it wants until the
// train's close offset. Only a lane with no dedicated train falls back to
@@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService {
computed.appliedModifiers,
);
// Pairing is reported, not thrown: the confirm modal shows it next to the
// price (as the customer form does) and disables confirm; /complete
// hard-blocks the same payload.
const pairingErrors =
booking.freightType === "CONTAINER"
? await this.pairingViolationMessages(dto)
: [];
return {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
overweightLines: computed.overweightLines,
pairingErrors,
};
}