mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +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 { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||||
import { CompaniesModule } from "./modules/companies/companies.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 { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
@@ -203,6 +204,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
SchedulingRescheduleModule,
|
SchedulingRescheduleModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
ShippingLineCompaniesModule,
|
ShippingLineCompaniesModule,
|
||||||
|
ShippingLineBookingCompletionModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
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,
|
bookingId: string,
|
||||||
scheduledDate: string,
|
scheduledDate: string,
|
||||||
requestedTrainScheduleId?: string | null,
|
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> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
@@ -971,20 +980,22 @@ export class BookingTransitionService {
|
|||||||
// gate; quantity never blocks — oversized bookings get a partial split
|
// gate; quantity never blocks — oversized bookings get a partial split
|
||||||
// offer). The batch engine assigns the specific train within that
|
// offer). The batch engine assigns the specific train within that
|
||||||
// (route, day) pool later.
|
// (route, day) pool later.
|
||||||
const { hasDeparture, hasCompatible } =
|
if (!opts?.bypassDayPool) {
|
||||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
const { hasDeparture, hasCompatible } =
|
||||||
booking,
|
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||||
eatDay(date),
|
booking,
|
||||||
);
|
eatDay(date),
|
||||||
if (!hasDeparture) {
|
);
|
||||||
throw new BadRequestException(
|
if (!hasDeparture) {
|
||||||
"No departures available on the selected day for this route",
|
throw new BadRequestException(
|
||||||
);
|
"No departures available on the selected day for this route",
|
||||||
}
|
);
|
||||||
if (!hasCompatible) {
|
}
|
||||||
throw new BadRequestException(
|
if (!hasCompatible) {
|
||||||
"No wagon on the selected day can carry this cargo type — please choose another day",
|
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
|
// 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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { ApprovalRulesController } from './controllers/approval-rules.controller';
|
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).
|
// against them (a cap above the rating is a typo, not a policy).
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
// Rates may be scoped to one shipping line; creating such a rate validates
|
// Rates may be scoped to one shipping line; creating such a rate validates
|
||||||
// the line exists and is active. forwardRef: shipping-lines now imports
|
// the line exists and is active.
|
||||||
// BookingsModule (booking completion), which imports this module back.
|
ShippingLineCompaniesModule,
|
||||||
forwardRef(() => ShippingLineCompaniesModule),
|
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
CargoTypesController,
|
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 { PortalCustomer } from "../../common/booking-guards";
|
||||||
import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto";
|
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 { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
|
||||||
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
|
||||||
|
|
||||||
@@ -88,33 +87,6 @@ export class ShippingLineBookingsController {
|
|||||||
return this.shippingLineBookingsService.findMine(user.id, id);
|
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")
|
@Post(":id/cancel")
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -8,28 +8,16 @@ import {
|
|||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { In, MoreThanOrEqual, Repository } from "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 { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity";
|
||||||
import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity";
|
import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { formatRouteLabel, Route } from "../routes/entities/route.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 { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||||
import { ServiceType } from "../rule-engine/entities/service-type.entity";
|
import { ServiceType } from "../rule-engine/entities/service-type.entity";
|
||||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.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 { 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 { ShippingLineCompaniesService } from "./shipping-line-companies.service";
|
||||||
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The only trade direction a shipping line books.
|
* The only trade direction a shipping line books.
|
||||||
@@ -77,11 +65,6 @@ export class ShippingLineBookingsService {
|
|||||||
@InjectRepository(Booking)
|
@InjectRepository(Booking)
|
||||||
private readonly bookingsRepository: Repository<Booking>,
|
private readonly bookingsRepository: Repository<Booking>,
|
||||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
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.
|
* 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 { FreightAuthModule } from "../auth/freight-auth.module";
|
||||||
import { BillingModule } from "../billing/billing.module";
|
import { BillingModule } from "../billing/billing.module";
|
||||||
import { BookingsModule } from "../bookings/bookings.module";
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { OtpModule } from "../otp/otp.module";
|
import { OtpModule } from "../otp/otp.module";
|
||||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
|
||||||
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
|
||||||
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
|
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
|
||||||
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
|
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
|
// `shipping_line_credit.invoice.paid` event, but the module graph now cycles
|
||||||
// (billing -> companies -> here -> billing), so this edge needs forwardRef.
|
// (billing -> companies -> here -> billing), so this edge needs forwardRef.
|
||||||
forwardRef(() => BillingModule),
|
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: [
|
controllers: [
|
||||||
ShippingLineCompaniesController,
|
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
|
* 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
|
* 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;
|
windowCloseHour!: number;
|
||||||
|
|
||||||
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
|
// 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> {
|
private async emitWindowState(scheduleId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
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) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
|
`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.
|
// and a newborn anchoring to it would inherit that dead window verbatim.
|
||||||
.andWhere('s.status != :cancelledStatus', {
|
.andWhere('s.status != :cancelledStatus', {
|
||||||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
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) {
|
if (excludeScheduleId) {
|
||||||
qb.andWhere('s.id != :excludeScheduleId', { 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
|
// 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
|
// export trains departing the same day at different times must keep their
|
||||||
// own departure-anchored windows.
|
// 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 =
|
const groupAnchor =
|
||||||
direction === 'EXPORT'
|
direction === 'EXPORT' || dto.shippingLineCompanyId
|
||||||
? null
|
? null
|
||||||
: await this.findGroupWindowAnchor(
|
: await this.findGroupWindowAnchor(
|
||||||
manager,
|
manager,
|
||||||
@@ -1678,45 +1689,71 @@ export class TrainSchedulingService {
|
|||||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||||
// windows from it rather than the live config.
|
// windows from it rather than the live config.
|
||||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
let windowFields: Partial<TrainSchedule>;
|
||||||
const computedTimes =
|
if (dto.shippingLineCompanyId) {
|
||||||
direction === 'EXPORT'
|
// Dedicated shipping-line departure: NO window cycle at all. The line
|
||||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
// books whenever it wants from creation until the close offset before
|
||||||
: {
|
// departure. windowPhase stays NULL, so the window engine, restamp and
|
||||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
// the customer window lists all skip this schedule; the close-offset
|
||||||
...ruleSnapshot,
|
// gate is enforced by the shipping-line completion path, which reads
|
||||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
// windowClosesAt stamped here.
|
||||||
};
|
const offsetMinutes = windowCfg.importCloseOffsetMinutes ?? 0;
|
||||||
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
|
const closesAt = new Date(departure.getTime() - offsetMinutes * 60_000);
|
||||||
// in the past — clamp it to `now` so the window tick opens it immediately.
|
if (closesAt.getTime() <= Date.now()) {
|
||||||
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
|
throw new BadRequestException(
|
||||||
computedTimes.windowOpensAt = new Date();
|
'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
|
// 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
|
// its wagons are allocated. Trains built without wagons yet fall back to
|
||||||
// the configured limit.
|
// the configured limit.
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ const RuleEngineFormDialog = ({
|
|||||||
current[name] ? { ...current, [name]: "" } : current,
|
current[name] ? { ...current, [name]: "" } : current,
|
||||||
);
|
);
|
||||||
setValues((current) => {
|
setValues((current) => {
|
||||||
|
// Mantine fires onChange even when the same option is re-picked, and the
|
||||||
|
// cascades below clear dependent answers (yards, unit, scope). Re-picking
|
||||||
|
// an unchanged value must be a no-op, or an untouched direction silently
|
||||||
|
// wipes the yard pair and the submit fails with "origin/destination
|
||||||
|
// missing" data the admin did fill in.
|
||||||
|
if (current[name] === value) return current;
|
||||||
const next = { ...current, [name]: value };
|
const next = { ...current, [name]: value };
|
||||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||||
// the previously-chosen unit — reset it so the admin re-picks from the new
|
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
|||||||
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
windowOpenHour: 8,
|
windowOpenHour: 8,
|
||||||
windowCloseHour: 17,
|
// Equal to open ⇒ 24-hour desk (the default).
|
||||||
|
windowCloseHour: 8,
|
||||||
windowDurationHours: 3,
|
windowDurationHours: 3,
|
||||||
docReviewMinutes: 30,
|
docReviewMinutes: 30,
|
||||||
paymentWindowMinutes: 60,
|
paymentWindowMinutes: 60,
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
|||||||
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
windowOpenHour: 8,
|
windowOpenHour: 8,
|
||||||
windowCloseHour: 17,
|
// Equal to open ⇒ 24-hour desk (the default).
|
||||||
|
windowCloseHour: 8,
|
||||||
windowDurationHours: 3,
|
windowDurationHours: 3,
|
||||||
docReviewMinutes: 30,
|
docReviewMinutes: 30,
|
||||||
paymentWindowMinutes: 60,
|
paymentWindowMinutes: 60,
|
||||||
|
|||||||
@@ -682,7 +682,11 @@ const RuleEngineResourcePage = () => {
|
|||||||
// ton·km, container = per km + distance band) and the currency stays as
|
// ton·km, container = per km + distance band) and the currency stays as
|
||||||
// chosen (birr or dollar). Everything else remains USD-only.
|
// chosen (birr or dollar). Everything else remains USD-only.
|
||||||
const isLastMile = values.appliesTo === "LAST_MILE";
|
const isLastMile = values.appliesTo === "LAST_MILE";
|
||||||
const { lastMileMode, ...rest } = values;
|
// The shipping-line toggle is form-only — the API's whitelist rejects the
|
||||||
|
// whole payload if it leaks through ("property isShippingLineRate should
|
||||||
|
// not exist").
|
||||||
|
const { lastMileMode, isShippingLineRate: _toggle, ...rest } = values;
|
||||||
|
void _toggle;
|
||||||
payload = {
|
payload = {
|
||||||
...rest,
|
...rest,
|
||||||
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export default function ShippingLineBookingDetailPage() {
|
|||||||
>
|
>
|
||||||
{status === "OPERATION_CHANGES_REQUESTED"
|
{status === "OPERATION_CHANGES_REQUESTED"
|
||||||
? "Resubmit booking"
|
? "Resubmit booking"
|
||||||
: "Complete booking"}
|
: "Book"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -317,7 +317,7 @@ export default function ShippingLineBookingDetailPage() {
|
|||||||
>
|
>
|
||||||
{status === "OPERATION_CHANGES_REQUESTED"
|
{status === "OPERATION_CHANGES_REQUESTED"
|
||||||
? "Resubmit booking"
|
? "Resubmit booking"
|
||||||
: "Complete booking"}
|
: "Book"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -41,9 +41,16 @@ import {
|
|||||||
DOC_STATE_COLOR,
|
DOC_STATE_COLOR,
|
||||||
DOC_STATE_LABEL,
|
DOC_STATE_LABEL,
|
||||||
} from "./booking-doc-state";
|
} from "./booking-doc-state";
|
||||||
|
import ShippingLineCompleteModal from "./ShippingLineCompleteModal";
|
||||||
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
|
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
|
||||||
import ShippingLineInitiateModal from "./ShippingLineInitiateModal";
|
import ShippingLineInitiateModal from "./ShippingLineInitiateModal";
|
||||||
|
|
||||||
|
/** Statuses where the booking is approved and waiting to be booked. */
|
||||||
|
const BOOKABLE_STATUSES = new Set([
|
||||||
|
"CLEARANCE_READY",
|
||||||
|
"OPERATION_CHANGES_REQUESTED",
|
||||||
|
]);
|
||||||
|
|
||||||
function ColHeader({ label }: { label: string }) {
|
function ColHeader({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<Text fz={12} fw={700} c="edr-muted" tt="uppercase" lts="0.04em">
|
<Text fz={12} fw={700} c="edr-muted" tt="uppercase" lts="0.04em">
|
||||||
@@ -67,6 +74,9 @@ export default function ShippingLineBookingsPage() {
|
|||||||
const [docsBooking, setDocsBooking] = useState<ShippingLineBooking | null>(
|
const [docsBooking, setDocsBooking] = useState<ShippingLineBooking | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [bookBooking, setBookBooking] = useState<ShippingLineBooking | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const bookingsQuery = useQuery({
|
const bookingsQuery = useQuery({
|
||||||
queryKey: ["shipping-line-bookings"],
|
queryKey: ["shipping-line-bookings"],
|
||||||
@@ -154,6 +164,7 @@ export default function ShippingLineBookingsPage() {
|
|||||||
const state = bookingDocState(booking);
|
const state = bookingDocState(booking);
|
||||||
const showDocs = hasDocuments(state);
|
const showDocs = hasDocuments(state);
|
||||||
const wantsUpload = needsUpload(state);
|
const wantsUpload = needsUpload(state);
|
||||||
|
const bookable = BOOKABLE_STATUSES.has(booking.status as string);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -162,7 +173,24 @@ export default function ShippingLineBookingsPage() {
|
|||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{showDocs && (
|
{/* Approved documents make booking the primary move — the docs
|
||||||
|
button steps back into the menu so one action owns the row. */}
|
||||||
|
{bookable && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
fw={700}
|
||||||
|
fz={13}
|
||||||
|
leftSection={<PackageCheck size={14} />}
|
||||||
|
onClick={() => setBookBooking(booking)}
|
||||||
|
>
|
||||||
|
{booking.status === "OPERATION_CHANGES_REQUESTED"
|
||||||
|
? "Resubmit"
|
||||||
|
: "Book"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showDocs && !bookable && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -214,19 +242,14 @@ export default function ShippingLineBookingsPage() {
|
|||||||
{DOC_STATE_ACTION_LABEL[state]}
|
{DOC_STATE_ACTION_LABEL[state]}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{/* Approved documents (or an operations return) unlock the
|
{bookable && (
|
||||||
completion step — it lives on the detail page. */}
|
|
||||||
{(booking.status === "CLEARANCE_READY" ||
|
|
||||||
booking.status === "OPERATION_CHANGES_REQUESTED") && (
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<PackageCheck size={15} />}
|
leftSection={<PackageCheck size={15} />}
|
||||||
onClick={() =>
|
onClick={() => setBookBooking(booking)}
|
||||||
navigate(`/shipping-line/bookings/${booking.id}`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{booking.status === "OPERATION_CHANGES_REQUESTED"
|
{booking.status === "OPERATION_CHANGES_REQUESTED"
|
||||||
? "Resubmit booking"
|
? "Resubmit booking"
|
||||||
: "Complete booking"}
|
: "Book"}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
@@ -325,6 +348,16 @@ export default function ShippingLineBookingsPage() {
|
|||||||
booking={docsBooking}
|
booking={docsBooking}
|
||||||
onClose={() => setDocsBooking(null)}
|
onClose={() => setDocsBooking(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ShippingLineCompleteModal
|
||||||
|
booking={bookBooking}
|
||||||
|
onClose={() => setBookBooking(null)}
|
||||||
|
onCompleted={(booking) => {
|
||||||
|
setBookBooking(null);
|
||||||
|
// Straight into the booking — its status and price just changed.
|
||||||
|
navigate(`/shipping-line/bookings/${booking.id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user