feat: implement shipping line booking completion functionality

- Added ShippingLineBookingCompletionController and associated service to handle the completion of shipping line bookings.
- Introduced a new module for booking completion to maintain module separation and avoid cyclic dependencies.
- Updated the train scheduling global rules to set default desk hours to 24 hours.
- Modified existing services and entities to accommodate the new booking completion logic.
- Enhanced the front-end components to support the new booking completion flow, including updates to the booking detail and bookings pages.
- Implemented validation and error handling for booking completion, ensuring that only approved bookings can be completed.
- Added migration to set default desk hours in the database.
This commit is contained in:
Marshal
2026-08-13 15:10:27 +00:00
parent 5837ac7b6e
commit 0e00a98ef3
18 changed files with 681 additions and 389 deletions

View File

@@ -40,6 +40,7 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module";
import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
@@ -203,6 +204,7 @@ if (!process.env.APPLICATION_NAME) {
SchedulingRescheduleModule,
CompaniesModule,
ShippingLineCompaniesModule,
ShippingLineBookingCompletionModule,
TrackingModule,
BillingModule,
NotificationsModule,

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Default the daily booking desk to 24 hours: window_close_hour equal to
* window_open_hour means the desk never pauses overnight. Aligns the column
* default and the existing global-rules row; per-schedule overrides keep
* whatever staff set on them.
*/
export class DefaultDeskHours24h3520000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_close_hour SET DEFAULT 8
`);
await queryRunner.query(`
UPDATE freight.train_scheduling_global_rules
SET window_close_hour = window_open_hour
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_close_hour SET DEFAULT 17
`);
await queryRunner.query(`
UPDATE freight.train_scheduling_global_rules
SET window_close_hour = 17
`);
}
}

View File

@@ -944,6 +944,15 @@ export class BookingTransitionService {
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
opts?: {
/**
* Skip the customer day-pool departure/compatibility gate. Used ONLY by
* the shipping-line completion path, which has already validated the day
* against the line's own dedicated train (those trains are excluded from
* the customer pools, so the gate here would wrongly reject them).
*/
bypassDayPool?: boolean;
},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -971,20 +980,22 @@ export class BookingTransitionService {
// 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",
);
if (!opts?.bypassDayPool) {
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",
);
}
}
// Export is FCFS and never splits — a booking must ride one train whole. So

View File

@@ -1,4 +1,4 @@
import { forwardRef, Global, Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApprovalRulesController } from './controllers/approval-rules.controller';
@@ -104,9 +104,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
// against them (a cap above the rating is a typo, not a policy).
WagonTypesModule,
// Rates may be scoped to one shipping line; creating such a rate validates
// the line exists and is active. forwardRef: shipping-lines now imports
// BookingsModule (booking completion), which imports this module back.
forwardRef(() => ShippingLineCompaniesModule),
// the line exists and is active.
ShippingLineCompaniesModule,
],
controllers: [
CargoTypesController,

View File

@@ -0,0 +1,53 @@
import { CurrentUser } from "@edr/api-common";
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { PortalCustomer } from "../../common/booking-guards";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service";
interface CurrentIamUser {
id: string;
}
/**
* The completion half of shipping-line bookings, sharing the
* `/shipping-line-bookings` prefix with {@link ShippingLineBookingsController}.
* Separate controller because it lives in its own module — see
* {@link ShippingLineBookingCompletionService} for why the module split exists.
*/
@ApiTags("shipping-line-bookings")
@Controller("shipping-line-bookings")
@ApiBearerAuth()
export class ShippingLineBookingCompletionController {
constructor(
private readonly completionService: ShippingLineBookingCompletionService,
) {}
@Get(":id/available-days")
@PortalCustomer()
@ApiOperation({
summary:
"Days with an open departure that can carry this booking's cargo — for the completion form's day picker.",
})
async availableDays(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.completionService.availableDaysMine(user.id, id);
}
@Post(":id/complete")
@PortalCustomer()
@ApiOperation({
summary:
"Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day. Prices off the line's rates, records the charge on the credit ledger and requests operation.",
})
async completeMine(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CompleteShippingLineBookingDto,
) {
return this.completionService.completeMine(user.id, id, dto);
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BookingsModule } from "../bookings/bookings.module";
import { Booking } from "../bookings/entities/booking.entity";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { ShippingLineBookingCompletionController } from "./shipping-line-booking-completion.controller";
import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service";
import { ShippingLineCompaniesModule } from "./shipping-line-companies.module";
/**
* Deliberately a LEAF module — registered in AppModule and imported by
* nothing. Completion needs BookingsModule (pricing + the operation-request
* transition), but ShippingLineCompaniesModule sits under rule-engine and
* companies, which sit under BookingsModule; importing bookings from there
* closes a module cycle Nest cannot construct. Keeping the completion flow
* here keeps the graph acyclic with no forwardRef chains.
*/
@Module({
imports: [
TypeOrmModule.forFeature([Booking]),
BookingsModule,
TrainSchedulingModule,
ShippingLineCompaniesModule,
],
controllers: [ShippingLineBookingCompletionController],
providers: [ShippingLineBookingCompletionService],
})
export class ShippingLineBookingCompletionModule {}

View File

@@ -0,0 +1,398 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
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 { BookingContainer } from "../bookings/entities/booking-container.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import { eatDay } from "../train-scheduling/batch-window.util";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
/**
* Completion of a shipping-line booking — the step after Operations approves
* its documents, mirroring what a customer does at that point: cargo + binding
* shipment day go in, the booking prices off the line's negotiated rates and
* the request lands with Operations.
*
* Its own module (not part of {@link ShippingLineBookingsService}) because it
* needs BookingsModule (pricing, the operation-request transition) and
* TrainSchedulingModule — and ShippingLineCompaniesModule is imported by
* rule-engine/companies, which sit UNDER BookingsModule. Importing bookings
* from there closes a module cycle Nest cannot construct; a leaf module that
* nothing imports keeps the graph acyclic.
*/
@Injectable()
export class ShippingLineBookingCompletionService {
constructor(
@InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
private readonly bookingsService: BookingsService,
private readonly bookingPricingService: BookingPricingService,
private readonly bookingTransitionService: BookingTransitionService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly creditsService: ShippingLineCreditsService,
) {}
/** Same session→owner resolution every shipping-line entry point uses. */
private async requireShippingLine(userId: string) {
const shippingLine =
await this.shippingLineCompaniesService.findByUserId(userId);
if (!shippingLine) {
throw new ForbiddenException("This account is not a shipping line.");
}
if (shippingLine.status !== "active") {
throw new ForbiddenException(
"This shipping-line account is suspended and cannot create bookings.",
);
}
return shippingLine;
}
private async requireOwnBooking(
userId: string,
bookingId: string,
relations?: { bookingContainers?: boolean },
) {
const shippingLine = await this.requireShippingLine(userId);
const booking = await this.bookingsRepository.findOne({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
relations,
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking;
}
/**
* The line's dedicated departures on the booking's lane (DRAFT/SCHEDULED,
* soonest first). These trains run NO booking-window cycle — the line books
* whenever it wants until the close offset stamped in `windowClosesAt` — and
* they are excluded from every customer pool, so this is the only source
* that can offer them.
*/
private async dedicatedTrainsForBooking(booking: Booking) {
if (!booking.shippingLineCompanyId) return [];
return this.bookingsRepository.manager.getRepository(TrainSchedule).find({
where: {
shippingLineCompanyId: booking.shippingLineCompanyId,
originStationId: booking.originYardId ?? undefined,
destinationStationId: booking.destinationYardId ?? undefined,
status: In(["DRAFT", "SCHEDULED"]),
},
order: { scheduledDepartureDate: "ASC" },
});
}
/** Still bookable: the close offset before departure has not passed yet. */
private isStillOpen(schedule: TrainSchedule): boolean {
const closesAt =
schedule.windowClosesAt ?? schedule.scheduledDepartureDate;
return closesAt.getTime() > Date.now();
}
/**
* Days the shipping line may pick as the shipment day.
*
* Lanes with trains DEDICATED to this line offer exactly those trains' days,
* open until each train's close offset — no window cycle. Lanes without a
* dedicated train fall back to the shared customer day pool, exactly as
* before. Ownership is checked first so one line cannot probe another's
* booking.
*/
async availableDaysMine(userId: string, bookingId: string) {
const booking = await this.requireOwnBooking(userId, bookingId);
const dedicated = await this.dedicatedTrainsForBooking(booking);
if (dedicated.length === 0) {
return this.bookingsService.availableDaysForBooking(bookingId);
}
const days = [
...new Set(
dedicated
.filter((s) => this.isStillOpen(s))
.map((s) => eatDay(s.scheduledDepartureDate)),
),
];
return { days };
}
/**
* Complete a bare shipping-line booking once Operations has approved its
* documents (CLEARANCE_READY), or after Operations returned the request
* (OPERATION_CHANGES_REQUESTED). The cargo and the binding shipment day go
* in, the booking is priced off the line's negotiated rates, and the request
* lands with Operations (OPERATION_REQUEST_PENDING) through the same
* transition customers use.
*
* Payment differs from customers by design: no invoice is issued here.
* Shipping lines run on the credit ledger — the priced amount is recorded as
* an UNBILLED credit and Finance bills a batch later, so the booking
* proceeds without a payment gate.
*/
async completeMine(
userId: string,
bookingId: string,
dto: CompleteShippingLineBookingDto,
) {
const booking = await this.requireOwnBooking(userId, bookingId, {
bookingContainers: true,
});
if (
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
booking.status,
)
) {
throw new BadRequestException(
"Your documents must be approved before the booking can be completed.",
);
}
// 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
// the customer window gate, unchanged.
const dedicated = await this.dedicatedTrainsForBooking(booking);
const pickedDay = eatDay(new Date(dto.scheduledDate));
const dedicatedOnDay = dedicated.filter(
(s) => eatDay(s.scheduledDepartureDate) === pickedDay,
);
let bypassDayPool = false;
if (dedicatedOnDay.length > 0) {
if (!dedicatedOnDay.some((s) => this.isStillOpen(s))) {
throw new BadRequestException(
"Booking for your train on this day has closed — the cut-off before departure has passed.",
);
}
// The day is backed by the line's own train, which every customer pool
// deliberately excludes — so the day-pool gate downstream must not run.
bypassDayPool = true;
} else if (dedicated.length > 0) {
throw new BadRequestException(
"Pick one of your assigned train days for this route.",
);
} else {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
scheduledDate: dto.scheduledDate,
direction: booking.tradeDirection ?? null,
});
}
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const restatesCargo = Boolean(
dto.containers?.length || dto.cargoTypeId || dto.cargoWeightTons,
);
// Operations may return the request asking for the CARGO to change, not
// just the day. A resubmit that restates cargo starts completion over:
// the recorded (unbilled) credit is written off and the persisted cargo
// wiped, so the fresh path below re-persists, re-prices and re-records.
// Once the credit is on an issued invoice the cargo is frozen — the
// invoice total must keep matching what it bills.
if (hasCargo && restatesCargo) {
const credit = await this.bookingsRepository.manager
.getRepository(ShippingLineCredit)
.findOne({ where: { bookingId } });
if (credit && credit.status === ShippingLineCreditStatus.Unbilled) {
await this.creditsService.cancelCredit(
credit.id,
"Cargo changed before billing — booking re-priced on completion.",
);
} else if (
credit &&
credit.status !== ShippingLineCreditStatus.Cancelled
) {
throw new BadRequestException(
"This booking's charge has already been invoiced — contact Operations to change its cargo.",
);
}
await this.wipeCargo(bookingId);
hasCargo = false;
}
// First completion persists cargo and prices the booking; a day-only
// resubmit after OPERATION_CHANGES_REQUESTED skips straight to the
// operation request with the cargo (and price) it already carries.
if (!hasCargo) {
if (booking.freightType === "CONTAINER") {
await this.persistContainerLines(booking, dto);
} else {
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
throw new BadRequestException(
"Bulk bookings need a cargo type and a total weight in tons.",
);
}
const cargoType = await this.bookingsRepository.manager
.getRepository(CargoType)
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
if (!cargoType) {
throw new NotFoundException(
`Cargo type ${dto.cargoTypeId} not found`,
);
}
}
await this.bookingsRepository.update(bookingId, {
cargoTypeId:
booking.freightType === "BULK" ? (dto.cargoTypeId ?? null) : null,
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
bulkTotalWeightTons:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
// Hazard is per-line for containers; the booking-level flag is what
// pricing bills the surcharge from.
isHazardous: (dto.containers ?? []).some(
(line) => Number(line.hazardousQuantity ?? 0) > 0,
),
// Completion fixes the cargo — and therefore the price — so it is also
// where the billing currency is chosen.
paymentCurrency: dto.paymentCurrency ?? booking.paymentCurrency,
} as never);
const loaded = await this.bookingsRepository.findOne({
where: { id: bookingId },
relations: { bookingContainers: true, serviceType: true },
});
const computed = await this.bookingPricingService.computePriceForBooking(
loaded ?? booking,
);
// A zero price or hard block means no rate is configured for this line
// on this lane. Roll the cargo back so the booking stays completable —
// the approved clearance is not lost — and surface why.
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
await this.wipeCargo(bookingId);
throw new BadRequestException(
computed.hardBlocked.length > 0
? computed.hardBlocked.join("; ")
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
);
}
await this.bookingsRepository.update(bookingId, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
// The charge goes on the line's credit ledger ("use now, pay later") —
// idempotent per booking, so a retried completion cannot double the debt.
await this.creditsService.recordCredit({
bookingId,
amount: computed.totalAmount,
currency: computed.currency,
description: `Freight service — booking ${booking.reference}`,
});
}
// Binding day, OPERATION_REQUEST_PENDING and the staff notification — the
// machine a customer booking uses. When the day is backed by a dedicated
// train, the customer day-pool gate is skipped (validated above instead).
return this.bookingTransitionService.requestOperation(
bookingId,
dto.scheduledDate,
null,
bypassDayPool ? { bypassDayPool: true } : undefined,
);
}
/**
* Persist the container lines of a CONTAINER completion. Same row shape the
* customer paths write (quantity per type, VGM totals, wagon share) — the
* per-unit ISO numbers customers also skip at booking time arrive later at
* yard operations.
*/
private async persistContainerLines(
booking: Booking,
dto: CompleteShippingLineBookingDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException("At least one container line is required.");
}
const containerTypeRepo =
this.bookingsRepository.manager.getRepository(ContainerType);
const containerRepo =
this.bookingsRepository.manager.getRepository(BookingContainer);
for (const line of lines) {
const containerType = await containerTypeRepo.findOne({
where: { id: line.containerTypeId, isActive: true },
});
if (!containerType) {
throw new NotFoundException(
`Container type ${line.containerTypeId} not found`,
);
}
const hazardous = Math.min(
Number(line.hazardousQuantity ?? 0),
line.quantity,
);
const reefer = Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
await containerRepo.save(
containerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
containerSize: containerType.sizeFt
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: hazardous,
reeferQuantity: reefer,
returnQuantity: 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
}),
);
}
}
/** Roll a failed/superseded completion back to the bare-booking shape. */
private async wipeCargo(bookingId: string): Promise<void> {
await this.bookingsRepository.manager
.getRepository(BookingContainer)
.softDelete({ bookingId });
await this.bookingsRepository.update(bookingId, {
cargoTypeId: null,
cargoTotalWeightVgm: 0,
bulkTotalWeightTons: null,
totalAmount: 0,
pricingBreakdown: null,
} as never);
}
}

View File

@@ -11,7 +11,6 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { PortalCustomer } from "../../common/booking-guards";
import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
@@ -88,33 +87,6 @@ export class ShippingLineBookingsController {
return this.shippingLineBookingsService.findMine(user.id, id);
}
@Get(":id/available-days")
@PortalCustomer()
@ApiOperation({
summary:
"Days with an open departure that can carry this booking's cargo — for the completion form's day picker.",
})
async availableDays(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.shippingLineBookingsService.availableDaysMine(user.id, id);
}
@Post(":id/complete")
@PortalCustomer()
@ApiOperation({
summary:
"Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day. Prices off the line's rates, records the charge on the credit ledger and requests operation.",
})
async completeMine(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CompleteShippingLineBookingDto,
) {
return this.shippingLineBookingsService.completeMine(user.id, id, dto);
}
@Post(":id/cancel")
@PortalCustomer()
@ApiOperation({

View File

@@ -8,28 +8,16 @@ import {
import { InjectRepository } from "@nestjs/typeorm";
import { In, MoreThanOrEqual, Repository } from "typeorm";
import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity";
import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { formatRouteLabel, Route } from "../routes/entities/route.entity";
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import { ServiceType } from "../rule-engine/entities/service-type.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
import {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
/**
* The only trade direction a shipping line books.
@@ -77,11 +65,6 @@ export class ShippingLineBookingsService {
@InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
private readonly bookingsService: BookingsService,
private readonly bookingPricingService: BookingPricingService,
private readonly bookingTransitionService: BookingTransitionService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly creditsService: ShippingLineCreditsService,
) {}
/**
@@ -393,267 +376,6 @@ export class ShippingLineBookingsService {
}));
}
/**
* Days the shipping line may pick as the shipment day — cargo-aware when the
* booking already carries cargo, departure-only before that. Same helper the
* customer day picker uses; ownership is checked first so one line cannot
* probe another's booking.
*/
async availableDaysMine(userId: string, bookingId: string) {
const shippingLine = await this.requireShippingLine(userId);
const owned = await this.bookingsRepository.exists({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
});
if (!owned) throw new NotFoundException(`Booking ${bookingId} not found`);
return this.bookingsService.availableDaysForBooking(bookingId);
}
/**
* Complete a bare shipping-line booking once Operations has approved its
* documents (CLEARANCE_READY), or after Operations returned the request
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
* {@link initiate}, mirroring what a customer does at this point: the cargo
* and the binding shipment day go in, the booking is priced off the line's
* negotiated rates, and the request lands with Operations
* (OPERATION_REQUEST_PENDING) through the same transition customers use.
*
* Payment differs from customers by design: no invoice is issued here.
* Shipping lines run on the credit ledger — the priced amount is recorded as
* an UNBILLED credit and Finance bills a batch later, so the booking
* proceeds without a payment gate.
*/
async completeMine(
userId: string,
bookingId: string,
dto: CompleteShippingLineBookingDto,
) {
const shippingLine = await this.requireShippingLine(userId);
const booking = await this.bookingsRepository.findOne({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
relations: { bookingContainers: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
booking.status,
)
) {
throw new BadRequestException(
"Your documents must be approved before the booking can be completed.",
);
}
// Completion is booking time: the route's booking window must be open —
// the same config-driven gate a customer booking passes.
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
scheduledDate: dto.scheduledDate,
direction: booking.tradeDirection ?? null,
});
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const restatesCargo = Boolean(
dto.containers?.length || dto.cargoTypeId || dto.cargoWeightTons,
);
// Operations may return the request asking for the CARGO to change, not
// just the day. A resubmit that restates cargo starts completion over:
// the recorded (unbilled) credit is written off and the persisted cargo
// wiped, so the fresh path below re-persists, re-prices and re-records.
// Once the credit is on an issued invoice the cargo is frozen — the
// invoice total must keep matching what it bills.
if (hasCargo && restatesCargo) {
const credit = await this.bookingsRepository.manager
.getRepository(ShippingLineCredit)
.findOne({ where: { bookingId } });
if (credit && credit.status === ShippingLineCreditStatus.Unbilled) {
await this.creditsService.cancelCredit(
credit.id,
"Cargo changed before billing — booking re-priced on completion.",
);
} else if (
credit &&
credit.status !== ShippingLineCreditStatus.Cancelled
) {
throw new BadRequestException(
"This booking's charge has already been invoiced — contact Operations to change its cargo.",
);
}
await this.wipeCargo(bookingId);
hasCargo = false;
}
// First completion persists cargo and prices the booking; a day-only
// resubmit after OPERATION_CHANGES_REQUESTED skips straight to the
// operation request with the cargo (and price) it already carries.
if (!hasCargo) {
if (booking.freightType === "CONTAINER") {
await this.persistContainerLines(booking, dto);
} else {
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
throw new BadRequestException(
"Bulk bookings need a cargo type and a total weight in tons.",
);
}
const cargoType = await this.bookingsRepository.manager
.getRepository(CargoType)
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
if (!cargoType) {
throw new NotFoundException(
`Cargo type ${dto.cargoTypeId} not found`,
);
}
}
await this.bookingsRepository.update(bookingId, {
cargoTypeId:
booking.freightType === "BULK" ? (dto.cargoTypeId ?? null) : null,
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
bulkTotalWeightTons:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
// Hazard is per-line for containers; the booking-level flag is what
// pricing bills the surcharge from.
isHazardous: (dto.containers ?? []).some(
(line) => Number(line.hazardousQuantity ?? 0) > 0,
),
// Completion fixes the cargo — and therefore the price — so it is also
// where the billing currency is chosen.
paymentCurrency: dto.paymentCurrency ?? booking.paymentCurrency,
} as never);
const loaded = await this.bookingsRepository.findOne({
where: { id: bookingId },
relations: { bookingContainers: true, serviceType: true },
});
const computed = await this.bookingPricingService.computePriceForBooking(
loaded ?? booking,
);
// A zero price or hard block means no rate is configured for this line
// on this lane. Roll the cargo back so the booking stays completable —
// the approved clearance is not lost — and surface why.
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
await this.wipeCargo(bookingId);
throw new BadRequestException(
computed.hardBlocked.length > 0
? computed.hardBlocked.join("; ")
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
);
}
await this.bookingsRepository.update(bookingId, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
// The charge goes on the line's credit ledger ("use now, pay later") —
// idempotent per booking, so a retried completion cannot double the debt.
await this.creditsService.recordCredit({
bookingId,
amount: computed.totalAmount,
currency: computed.currency,
description: `Freight service — booking ${booking.reference}`,
});
}
// Binding day + open-departure validation, OPERATION_REQUEST_PENDING and
// the staff notification — the exact machine a customer booking uses.
await this.bookingTransitionService.requestOperation(
bookingId,
dto.scheduledDate,
null,
);
return this.findMine(userId, bookingId);
}
/**
* Persist the container lines of a CONTAINER completion. Same row shape the
* customer paths write (quantity per type, VGM totals, wagon share) — the
* per-unit ISO numbers customers also skip at booking time arrive later at
* yard operations.
*/
private async persistContainerLines(
booking: Booking,
dto: CompleteShippingLineBookingDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException(
"At least one container line is required.",
);
}
const containerTypeRepo =
this.bookingsRepository.manager.getRepository(ContainerType);
const containerRepo =
this.bookingsRepository.manager.getRepository(BookingContainer);
for (const line of lines) {
const containerType = await containerTypeRepo.findOne({
where: { id: line.containerTypeId, isActive: true },
});
if (!containerType) {
throw new NotFoundException(
`Container type ${line.containerTypeId} not found`,
);
}
const hazardous = Math.min(
Number(line.hazardousQuantity ?? 0),
line.quantity,
);
const reefer = Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
await containerRepo.save(
containerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
containerSize: containerType.sizeFt
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: hazardous,
reeferQuantity: reefer,
returnQuantity: 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
}),
);
}
}
/** Roll a failed/superseded completion back to the bare-booking shape. */
private async wipeCargo(bookingId: string): Promise<void> {
await this.bookingsRepository.manager
.getRepository(BookingContainer)
.softDelete({ bookingId });
await this.bookingsRepository.update(bookingId, {
cargoTypeId: null,
cargoTotalWeightVgm: 0,
bulkTotalWeightTons: null,
totalAmount: 0,
pricingBreakdown: null,
} as never);
}
/**
* Cancel one of the signed-in shipping line's own bookings.
*

View File

@@ -5,10 +5,8 @@ import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { FreightAuthModule } from "../auth/freight-auth.module";
import { BillingModule } from "../billing/billing.module";
import { BookingsModule } from "../bookings/bookings.module";
import { Booking } from "../bookings/entities/booking.entity";
import { OtpModule } from "../otp/otp.module";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
@@ -38,12 +36,6 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service";
// `shipping_line_credit.invoice.paid` event, but the module graph now cycles
// (billing -> companies -> here -> billing), so this edge needs forwardRef.
forwardRef(() => BillingModule),
// Booking completion reuses the customer machinery: pricing, the
// operation-request transition and the day picker. Both edges cycle back
// here (bookings -> rule-engine -> shipping-lines, train-scheduling ->
// bookings -> …), so both need forwardRef.
forwardRef(() => BookingsModule),
forwardRef(() => TrainSchedulingModule),
],
controllers: [
ShippingLineCompaniesController,

View File

@@ -57,9 +57,10 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
/**
* Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full
* train whose next cycle would reopen at/after this hour pauses until the next
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk.
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk
* (the default).
*/
@Column({ name: 'window_close_hour', type: 'int', default: 17 })
@Column({ name: 'window_close_hour', type: 'int', default: 8 })
windowCloseHour!: number;
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)

View File

@@ -471,7 +471,11 @@ export class TrainSchedulingService {
private async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
// Dedicated shipping-line departures are never announced to the portal —
// the broadcast reaches every customer client.
if (fresh && !fresh.shippingLineCompanyId) {
this.bookingWindowGateway.emitPhase(fresh);
}
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
@@ -512,7 +516,11 @@ export class TrainSchedulingService {
// and a newborn anchoring to it would inherit that dead window verbatim.
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
})
// A dedicated shipping-line departure is never a sibling either: it runs
// no window cycle, so it must neither anchor a customer group nor be
// dragged through one's open/doc-review/payment instants.
.andWhere('s.shippingLineCompanyId IS NULL');
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
@@ -1612,8 +1620,11 @@ export class TrainSchedulingService {
// doc-review/payment phase — so there is no cross-expiry to fix, and two
// export trains departing the same day at different times must keep their
// own departure-anchored windows.
// Dedicated shipping-line departures never group either: they run no
// window cycle at all, so sharing a customer group's timeline (or
// anchoring one) would drag them into phases they must not have.
const groupAnchor =
direction === 'EXPORT'
direction === 'EXPORT' || dto.shippingLineCompanyId
? null
: await this.findGroupWindowAnchor(
manager,
@@ -1678,45 +1689,71 @@ export class TrainSchedulingService {
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
let windowFields: Partial<TrainSchedule>;
if (dto.shippingLineCompanyId) {
// Dedicated shipping-line departure: NO window cycle at all. The line
// books whenever it wants from creation until the close offset before
// departure. windowPhase stays NULL, so the window engine, restamp and
// the customer window lists all skip this schedule; the close-offset
// gate is enforced by the shipping-line completion path, which reads
// windowClosesAt stamped here.
const offsetMinutes = windowCfg.importCloseOffsetMinutes ?? 0;
const closesAt = new Date(departure.getTime() - offsetMinutes * 60_000);
if (closesAt.getTime() <= Date.now()) {
throw new BadRequestException(
'With the booking-close offset applied, this departure would already be ' +
'closed for shipping-line booking — pick a later departure.',
);
}
windowFields = {
bookingWindowStatus: 'OPEN',
windowPhase: null,
windowOpensAt: new Date(),
windowClosesAt: closesAt,
ruleImportCloseOffsetMinutes: offsetMinutes || null,
windowRuleCustom: dto.windowRule != null,
};
} else {
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
throw new BadRequestException(
'These booking-window settings leave no window before departure — with the ' +
'desk hours and close offset applied, the window would only open once the ' +
'train has left.',
);
}
windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
// live global value for the direction), so an explicit staff override is
// persisted here — the same field the post-creation override writes.
...(dto.windowRule?.paymentWindowMinutes !== undefined
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
: {}),
// Hand-configured windows opt OUT of the global re-stamp, or the next
// global-rules edit would overwrite exactly what staff chose here.
windowRuleCustom: dto.windowRule != null,
};
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
throw new BadRequestException(
'These booking-window settings leave no window before departure — with the ' +
'desk hours and close offset applied, the window would only open once the ' +
'train has left.',
);
}
const windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
// live global value for the direction), so an explicit staff override is
// persisted here — the same field the post-creation override writes.
...(dto.windowRule?.paymentWindowMinutes !== undefined
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
: {}),
// Hand-configured windows opt OUT of the global re-stamp, or the next
// global-rules edit would overwrite exactly what staff chose here.
windowRuleCustom: dto.windowRule != null,
};
// A built train's own consist is the schedule's capacity: full when all
// its wagons are allocated. Trains built without wagons yet fall back to
// the configured limit.