changes export flow

This commit is contained in:
Marshal
2026-07-30 11:30:34 +00:00
parent b671f07ce6
commit 2ca04b8f98
47 changed files with 1195 additions and 74 deletions

View File

@@ -16,6 +16,7 @@ import {
containersPerWagonForSize,
wagonsPerUnitForSize,
} from '../rule-engine/container-type.util';
import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util';
import { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -1186,6 +1187,10 @@ export class BookingPricingService {
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
);
if (!(capacity > 0)) return null;
// Break-bulk (PER_ITEM): `tons` above is the item count; size by
// indivisible items instead of pretending the count is tonnage.
const byItems = bulkItemWagonsRequired(booking, capacity);
if (byItems > 0) return byItems;
return Math.max(1, Math.ceil(tons / capacity));
} catch {
return null;

View File

@@ -26,6 +26,7 @@ describe('BookingTransitionService — operation review', () => {
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
};
const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(),
@@ -144,6 +145,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
checkDayCompatibilityForBooking: jest
.fn()
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),

View File

@@ -9,7 +9,10 @@ import {
} from "@nestjs/common";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import {
BookingBatchService,
type ExportTrainOption,
} from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
@@ -401,6 +404,31 @@ export class BookingTransitionService {
return fresh;
}
/**
* Customer cancels their own unpaid hold (SELECTED_FOR_BATCH): the wagons
* release immediately instead of tying up the train until the pay window
* lapses. Ends CANCELLED; the freed capacity tops up from the waiting list.
*/
async cancelHold(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
if (booking.consolidationPartnerId) {
throw new BadRequestException(
"This booking shares a consolidated wagon with another booking — " +
"contact support to cancel it.",
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
reason ?? "Customer cancelled before payment",
"REJECTION",
);
await this.bookingBatchService.cancelReservation(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.cancelled(fresh, reason ?? "Cancelled before payment");
return fresh;
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -891,6 +919,7 @@ export class BookingTransitionService {
async requestOperation(
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -898,6 +927,13 @@ export class BookingTransitionService {
"OPERATION_CHANGES_REQUESTED",
]);
// A company sitting on another unpaid hold commits nothing new — this is
// the moment export capacity locks, so the lock applies here too.
// Government bookings allocate without paying and are exempt.
if (!booking.isGovernment) {
await this.bookingsService.assertNoUnpaidHold(booking.companyId);
}
// A bare initiated instance (clearance-first flow) carries no cargo or
// price — it must go through the contract completion endpoint, which
// persists cargo, prices, invoices and only then lands here itself.
@@ -942,10 +978,18 @@ export class BookingTransitionService {
// largest bookable leftover ("reduce to N wagons or pick another day").
// Import/domestic bookings are batched + splittable, so they are NOT gated
// here — they get an advisory count below and the batch engine sizes them.
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
const isExportTrain =
booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType);
// The customer's train pick only exists for export rail; it rides the
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
const scheduledBooking = {
...booking,
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as Booking;
if (isExportTrain) {
// With export split ON the booking no longer has to ride ONE train whole:
// the largest fitting part is offered and the leftover rebooks on the next
@@ -958,9 +1002,14 @@ export class BookingTransitionService {
eatDay(date),
"EXPORT",
);
if (!fitting.length) {
const fitsRequest = requestedId
? fitting.some((f) => f.scheduleId === requestedId)
: fitting.length > 0;
if (!fitsRequest) {
throw new ConflictException(
"No export train on this day has space left — pick another shipment day.",
requestedId
? "The selected train has no space left — pick another train or day."
: "No export train on this day has space left — pick another shipment day.",
);
}
} else {
@@ -971,6 +1020,7 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
@@ -988,6 +1038,35 @@ export class BookingTransitionService {
* total covers the booking. `trainsForDay` is false when no departure carries
* the leg — the day is unbookable regardless of space.
*/
/**
* Export train picker data for a shipment day the customer is choosing:
* each export train on the booking's corridor with per-wagon-type free
* space. Export rail bookings only — nothing else picks a train.
*/
async exportTrainsForBooking(
bookingId: string,
scheduledDate: string,
): Promise<ExportTrainOption[]> {
const booking = await this.bookingsService.findById(bookingId);
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");
}
if (
booking.tradeDirection !== "EXPORT" ||
isRoadService(booking.serviceType)
) {
throw new BadRequestException(
"Train selection is only available for export rail bookings",
);
}
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
return this.bookingBatchService.exportTrainOptionsForDay(
scheduledBooking,
eatDay(date),
);
}
async dayAvailabilityForBooking(
bookingId: string,
scheduledDate: string,

View File

@@ -751,10 +751,24 @@ export class BookingsController {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/export-trains")
@ApiOperation({
summary:
"Export train picker: the day's export trains on the booking's corridor " +
"with per-wagon-type free space (export rail bookings only)",
})
async exportTrainsForBooking(
@Param("id", ParseUUIDPipe) id: string,
@Query("date") date: string,
) {
return this.transitionService.exportTrainsForBooking(id, date);
}
@Post(":id/operation/review")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
@@ -1271,6 +1285,20 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold")
@ApiOperation({
summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
"reserved wagons release immediately",
})
async cancelHold(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancelHold(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/consolidation")
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {

View File

@@ -1316,6 +1316,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Open unpaid holds (wagons reserved, pay window running) for a company. */
countUnpaidHoldsForCompany(companyId: string): Promise<number> {
return this.repository.count({
where: {
companyId,
status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']),
},
});
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -602,6 +602,24 @@ export class BookingsService {
return result.booking;
}
/**
* A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved,
* pay window running) may not take more capacity until it pays or the hold
* dies: otherwise one customer can lock a train's wagons over and over
* without ever paying. EXPIRED / CANCELLED holds free the lock.
*/
async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
if (!companyId) return;
const holds =
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
if (holds > 0) {
throw new ConflictException(
'You already have a booking waiting for payment. Pay it or cancel it ' +
'before making a new booking.',
);
}
}
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -664,6 +682,10 @@ export class BookingsService {
companyId = company.id;
}
// Government bookings allocate without paying, so the unpaid-hold lock
// only applies to commercial companies.
if (!isGovernment) await this.assertNoUnpaidHold(companyId);
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
@@ -857,6 +879,9 @@ export class BookingsService {
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
bulkTotalWeightTons:
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer is the customer's toggle; container reefer is derived from
// the container type at pricing time, so the booking-level flag stays off
@@ -1048,6 +1073,11 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
bulkTotalWeightTons:
freightType === 'BULK'
? (dto.bulkTotalWeightTons ?? existing.bulkTotalWeightTons ?? null)
: null,
// Booking-level reefer is only meaningful for bulk; container reefer is
// derived from the container type at pricing time.
isReefer:

View File

@@ -325,6 +325,21 @@ export class CreateBookingDto {
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
/**
* Break-bulk only: actual total cargo weight in tons when the bulk cargo
* type is PER_ITEM — `cargoTotalWeightVgm` then carries the item count.
* Omit for PER_TON bulk and container freight.
*/
@ApiPropertyOptional({
minimum: 0,
description: 'Break-bulk (PER_ITEM) total weight in tons; cargoTotalWeightVgm holds the item count',
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value == null ? undefined : Number(value)))
bulkTotalWeightTons?: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()

View File

@@ -5,6 +5,7 @@ import {
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
MinLength,
@@ -93,6 +94,17 @@ export class RequestOperationDto {
})
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'EXPORT rail only: the specific train (schedule id) the customer picked ' +
'from GET /bookings/:id/export-trains. The reserve path locks onto this ' +
'train instead of earliest-first; 409 if it no longer fits. Ignored for ' +
'import/domestic/road bookings.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
}
export class OperationReviewDto {

View File

@@ -351,6 +351,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
cargoTotalWeightVgm!: number;
/**
* Break-bulk only: actual total cargo weight in tons when the bulk cargo
* type is PER_ITEM (`cargoTotalWeightVgm` then carries the item COUNT).
* Null for PER_TON bulk and all CONTAINER bookings. Wagon allocation uses
* weight ÷ count to size indivisible items per wagon.
*/
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
bulkTotalWeightTons?: number | null;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@@ -485,6 +494,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
/**
* EXPORT only: the specific train the customer picked at day-commit.
* pickExportSchedule reserves on this train (409 if it no longer fits)
* instead of falling back to earliest-departure-first. NULL = no preference.
*/
@Column({ name: 'requested_train_schedule_id', type: 'uuid', nullable: true })
requestedTrainScheduleId?: string | null;
/** Stamped when the one pre-deadline pay reminder went out (tick dedup). */
@Column({ name: 'payment_reminder_sent_at', type: 'timestamptz', nullable: true })
paymentReminderSentAt?: Date | null;
// ── Per-booking journey (segment corridor bookings) ────────────────────────
// A booking rides only its own origin→destination leg of the train's route,
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates