mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
changes export flow
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user