This commit is contained in:
Marshal
2026-07-15 13:29:01 +00:00
parent c71a0043d6
commit 19c9da28ae
59 changed files with 3008 additions and 284 deletions

View File

@@ -1003,18 +1003,26 @@ export class BookingTransitionService {
}
// The binding shipment day must have at least one OPEN departure on the
// route — only schedule-backed days are selectable. The batch engine
// assigns the specific train within that (route, day) pool later.
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
booking.originYardId,
booking.destinationYardId,
eatDay(date),
);
// route — only schedule-backed days are selectable — AND some departure
// that day must be able to physically carry this cargo type (wagon-TYPE
// gate; quantity never blocks — oversized bookings get a partial split
// offer). The batch engine assigns the specific train within that
// (route, day) pool later.
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
"No departures available on the selected day for this route",
);
}
if (!hasCompatible) {
throw new BadRequestException(
"No wagon on the selected day can carry this cargo type — please choose another day",
);
}
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",

View File

@@ -348,6 +348,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/available-days')
@ApiOperation({
summary:
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
})
async availableDays(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.bookingsService.availableDaysForBooking(id);
}
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',

View File

@@ -653,22 +653,37 @@ export class BookingsService {
} else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on
// that EAT day. The booking wizard does NOT send scheduledDate at creation
// — it captures a non-binding estimatedShipmentDate instead, and the
// binding day is chosen later at the operation-request step. General
// contracts also skip this (each drawdown order validates its own day).
// that EAT day AND that some departure that day can physically carry the
// cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
// a partial split offer later). The booking wizard does NOT send
// scheduledDate at creation — it captures a non-binding
// estimatedShipmentDate instead, and the binding day is chosen later at
// the operation-request step. General contracts also skip this (each
// drawdown order validates its own day).
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.checkDayCargoCompatibility(
dto.originYardId,
dto.destinationYardId,
day,
{
freightType: dto.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: dto.cargoTypeId,
containerTypeIds: (dto.containers ?? [])
.map((c) => c.containerTypeId)
.filter((id): id is string => Boolean(id)),
},
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
if (!hasCompatible) {
throw new BadRequestException(
'No wagon on the selected day can carry this cargo type — please choose another day',
);
}
}
const containers = dto.containers ?? [];
@@ -1149,6 +1164,52 @@ export class BookingsService {
);
}
/** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
private cargoIdentityOf(booking: Booking): {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
containerTypeIds?: string[];
} {
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId ?? null,
containerTypeIds: (booking.bookingContainers ?? [])
.map((line) => line.containerTypeId)
.filter((id): id is string => Boolean(id)),
};
}
/**
* Day gate for a specific booking: OPEN departure exists AND some departure
* that day can physically carry the booking's cargo/container type.
* Quantity never blocks — oversized bookings get a partial split offer.
*/
async checkDayCompatibilityForBooking(
booking: Booking,
day: string,
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
return this.trainSchedulingService.checkDayCargoCompatibility(
booking.originYardId,
booking.destinationYardId,
day,
this.cargoIdentityOf(booking),
);
}
/**
* Days the customer may pick for THIS booking (operation-request step):
* cargo-aware — only days whose departures can carry the booking's cargo
* type. Returns days only, no capacity counts.
*/
async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
const booking = await this.findById(bookingId);
return this.trainSchedulingService.getAvailableDaysForCargo({
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
...this.cargoIdentityOf(booking),
});
}
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal