wagon work space, container validation

This commit is contained in:
Marshal
2026-07-10 23:29:28 +00:00
parent 5d8658b5d6
commit dd87c2a722
12 changed files with 878 additions and 47 deletions

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
@@ -192,6 +193,11 @@ export class ContractBookingService {
// their only chance to hard-block an unbalanceable set. Entry order is
// irrelevant (the check sorts by weight before pairing).
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(dto, {
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
});
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
@@ -575,6 +581,15 @@ export class ContractBookingService {
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(
dto,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
await this.persistContainers(booking.id, contract, dto);
}
await this.bookingsRepository.update(booking.id, {
@@ -653,6 +668,10 @@ export class ContractBookingService {
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
// numbers must be free on the newly chosen train day too.
await this.assertPersistedContainersAvailable(booking, dto.scheduledDate);
}
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
@@ -1344,6 +1363,131 @@ export class ContractBookingService {
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
/**
* A physical container rides one train only. Reject the submission when a
* container number is entered twice in the same booking (the portal checks
* this client-side, the API must not trust it) or already sits on another
* customer's active booking for the same train — same shipment day AND same
* route (origin/destination yards).
*/
private async assertContainerNumbersAvailable(
dto: CreateBookingUnderContractDto,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const numbers = (dto.containers ?? []).flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
if (!numbers.length) return;
const seen = new Set<string>();
const withinBooking = new Set<string>();
for (const n of numbers) {
if (seen.has(n)) withinBooking.add(n);
seen.add(n);
}
if (withinBooking.size) {
throw new BadRequestException(
`Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`,
);
}
// Intercity bookings have no shipment day yet — nothing to clash with.
if (!dto.scheduledDate) return;
await this.assertNumbersFreeOnTrain(
numbers,
dto.scheduledDate,
route,
excludeBookingId,
);
}
/**
* Same train guard for a booking whose containers are already persisted
* (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its
* stored numbers must be free on the newly chosen day for its route.
*/
private async assertPersistedContainersAvailable(
booking: Booking,
scheduledDate: string,
): Promise<void> {
const rows: Array<{ containerNumber: string }> = await this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.select('unit.container_number', 'containerNumber')
.where('line.booking_id = :bookingId', { bookingId: booking.id })
.getRawMany();
const numbers = rows.map((r) => r.containerNumber).filter(Boolean);
if (!numbers.length) return;
await this.assertNumbersFreeOnTrain(
numbers,
scheduledDate,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
}
/**
* Reject when any of `numbers` sits on another active booking of the same
* train — same day and same route. Bookings without route yards (legacy
* rows) are matched on the day alone rather than let through.
*/
private async assertNumbersFreeOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const qb = this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.innerJoin(Booking, 'b', 'b.id = line.booking_id')
.select('unit.container_number', 'containerNumber')
.addSelect('b.reference', 'reference')
.where('unit.container_number IN (:...numbers)', { numbers })
.andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.andWhere('b.deleted_at IS NULL');
if (route.originYardId && route.destinationYardId) {
// Same train = same day + same corridor. A clashing booking whose yards
// were never denormalized still blocks (NULL yards match any route).
qb.andWhere(
'(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)',
{ originYardId: route.originYardId },
).andWhere(
'(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)',
{ destinationYardId: route.destinationYardId },
);
}
if (excludeBookingId) {
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
}
const clashes: Array<{ containerNumber: string; reference: string }> =
await qb.getRawMany();
if (clashes.length) {
const detail = [
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
]
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
}
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {