mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user