diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d869ddf2d..d38c6ea92 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts b/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts new file mode 100644 index 000000000..8cd71eff9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 7cc92be87..944e3db15 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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 { 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 diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 7d15ab889..549a35fa2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts new file mode 100644 index 000000000..155988a09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts new file mode 100644 index 000000000..470984c70 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts new file mode 100644 index 000000000..a7c938380 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts @@ -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, + 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 { + 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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts index 243fd0a76..8a8e8ef2b 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts @@ -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({ diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts index 333cab619..b9656807d 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts @@ -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, 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 { - 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 { - 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. * diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts index f8f682dde..698dfb384 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 94882833b..50debc6c0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -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) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index a908a6926..7125e3bbf 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -471,7 +471,11 @@ export class TrainSchedulingService { private async emitWindowState(scheduleId: string): Promise { 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; + 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. diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 8682844d8..e05e00917 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -227,6 +227,12 @@ const RuleEngineFormDialog = ({ current[name] ? { ...current, [name]: "" } : 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 }; // 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 diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index d09bce567..9bf95a01f 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -27,7 +27,8 @@ import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling"; /** Fallbacks matching the API's global-rules defaults (used when a field is null). */ const DEFAULTS = { windowOpenHour: 8, - windowCloseHour: 17, + // Equal to open ⇒ 24-hour desk (the default). + windowCloseHour: 8, windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx index b0de39baa..6d618eb64 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx @@ -22,7 +22,8 @@ import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling"; /** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */ const DEFAULTS = { windowOpenHour: 8, - windowCloseHour: 17, + // Equal to open ⇒ 24-hour desk (the default). + windowCloseHour: 8, windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index bdb5dfd9c..a2a33ac4d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -682,7 +682,11 @@ const RuleEngineResourcePage = () => { // ton·km, container = per km + distance band) and the currency stays as // chosen (birr or dollar). Everything else remains USD-only. 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 = { ...rest, currency: isLastMile ? (values.currency ?? "ETB") : "USD", diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingDetailPage.tsx index 457a2dbc9..669c9d610 100644 --- a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingDetailPage.tsx @@ -220,7 +220,7 @@ export default function ShippingLineBookingDetailPage() { > {status === "OPERATION_CHANGES_REQUESTED" ? "Resubmit booking" - : "Complete booking"} + : "Book"} )} @@ -317,7 +317,7 @@ export default function ShippingLineBookingDetailPage() { > {status === "OPERATION_CHANGES_REQUESTED" ? "Resubmit booking" - : "Complete booking"} + : "Book"} )} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingsPage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingsPage.tsx index e8cd132fa..a67fb4f64 100644 --- a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingsPage.tsx @@ -41,9 +41,16 @@ import { DOC_STATE_COLOR, DOC_STATE_LABEL, } from "./booking-doc-state"; +import ShippingLineCompleteModal from "./ShippingLineCompleteModal"; import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal"; 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 }) { return ( @@ -67,6 +74,9 @@ export default function ShippingLineBookingsPage() { const [docsBooking, setDocsBooking] = useState( null, ); + const [bookBooking, setBookBooking] = useState( + null, + ); const bookingsQuery = useQuery({ queryKey: ["shipping-line-bookings"], @@ -154,6 +164,7 @@ export default function ShippingLineBookingsPage() { const state = bookingDocState(booking); const showDocs = hasDocuments(state); const wantsUpload = needsUpload(state); + const bookable = BOOKABLE_STATUSES.has(booking.status as string); return ( 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 && ( + + )} + {showDocs && !bookable && (