From e2189040fa3824d8af52181f72f168d50daef3b2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 23 Aug 2026 04:49:58 +0000 Subject: [PATCH] feat: add pagination to schedule history and consolidation approvals - Implemented pagination in ScheduleHistoryPanel to manage large history entries. - Updated API to support pagination parameters for schedule history. - Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows. - Introduced new types for paginated responses in bookings and train scheduling services. - Added a database migration to create an index on wagon_booking_allocations for performance improvements. --- .../3680000000000-WagonAllocationSlotIndex.ts | 24 + .../modules/bookings/bookings.controller.ts | 685 +++++++++++------- .../consolidation-approval.service.spec.ts | 332 +++++++-- .../consolidation-approval.service.ts | 152 +++- .../consolidation-approvals.repository.ts | 136 +++- .../train-schedules.repository.ts | 24 + .../train-scheduling.controller.ts | 18 +- .../services/train-scheduling.service.ts | 393 ++++++---- .../trainScheduling/ScheduleHistoryPanel.tsx | 20 +- .../bookings/ConsolidationApprovalsPage.tsx | 353 ++++++--- .../TrainScheduleV2DetailPage.tsx | 39 +- .../backoffice/src/services/api.ts | 26 +- .../src/services/bookings.service.ts | 133 +++- .../src/services/trainBuilder.service.ts | 6 +- .../src/services/trainScheduling.service.ts | 18 + 15 files changed, 1746 insertions(+), 613 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts diff --git a/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts b/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts new file mode 100644 index 000000000..c5c855fb5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3680000000000-WagonAllocationSlotIndex.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Every slot → allocations lookup (allocator, journey load/unload, settle, + * per-leg weight guard) filters wagon_booking_allocations by + * train_set_wagon_id, which had no index — only booking_id and the pkey. + * Sequential scans grow with every allocation ever written. + */ +export class WagonAllocationSlotIndex3680000000000 implements MigrationInterface { + name = 'WagonAllocationSlotIndex3680000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_slot + ON freight.wagon_booking_allocations (train_set_wagon_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_wagon_booking_allocations_slot + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index b216df4cf..03f13d186 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -17,57 +17,59 @@ import { UploadedFile, UploadedFiles, UseInterceptors, -} from '@nestjs/common'; -import { CurrentUser } from '@edr/api-common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +} from "@nestjs/common"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff, BookingView, MixedAudience, PortalCustomer, WagonCancellationView, -} from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; -import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; +} from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { AnyFilesInterceptor, FileInterceptor } from "@nestjs/platform-express"; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, + ApiQuery, ApiTags, } from "@nestjs/swagger"; import type { Response } from "express"; -import { BookingClearanceChargeService } from './booking-clearance-charge.service'; -import { BookingPayablesService } from './booking-payables.service'; -import { ClearanceEventService } from './clearance-event.service'; +import { BookingClearanceChargeService } from "./booking-clearance-charge.service"; +import { BookingPayablesService } from "./booking-payables.service"; +import { ClearanceEventService } from "./clearance-event.service"; +import { RejectClearanceChargeDto } from "./dto/clearance-charge.dto"; +import { BillClearanceChargeDto } from "./dto/clearance-charge.dto"; +import { AdditionalChargeService } from "./additional-charge.service"; import { - - RejectClearanceChargeDto, -} from './dto/clearance-charge.dto'; -import { BillClearanceChargeDto } from './dto/clearance-charge.dto'; -import { AdditionalChargeService } from './additional-charge.service'; -import { CancelAdditionalChargeDto, CreateAdditionalChargeDto } from './dto/additional-charge.dto'; -import { BookingContractService } from './booking-contract.service'; -import { BookingPricingService } from './booking-pricing.service'; -import { BookingTransitionService } from './booking-transition.service'; -import { BookingClearanceService } from '../contracts/booking-clearance.service'; + CancelAdditionalChargeDto, + CreateAdditionalChargeDto, +} from "./dto/additional-charge.dto"; +import { BookingContractService } from "./booking-contract.service"; +import { BookingPricingService } from "./booking-pricing.service"; +import { BookingTransitionService } from "./booking-transition.service"; +import { BookingClearanceService } from "../contracts/booking-clearance.service"; import { AdviseContractDutyDto, RoAmendmentDto, -} from '../contracts/dto/phased-clearance.dto'; -import { BookingReferenceDataService } from './booking-reference-data.service'; -import { scopedDirections } from '../user-trade-access/trade-scope.util'; -import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { BookingsService } from './bookings.service'; -import { ConsolidationApprovalService } from './consolidation-approval.service'; -import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; -import { CreateBookingDto } from './dto/create-booking.dto'; -import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; -import { FilterBookingDto } from './dto/filter-booking.dto'; -import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; -import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +} from "../contracts/dto/phased-clearance.dto"; +import { BookingReferenceDataService } from "./booking-reference-data.service"; +import { scopedDirections } from "../user-trade-access/trade-scope.util"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; +import { BookingsService } from "./bookings.service"; +import { ConsolidationApprovalService } from "./consolidation-approval.service"; +import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity"; +import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; +import { CreateBookingDto } from "./dto/create-booking.dto"; +import { BookingListSummaryDto } from "./dto/booking-list-summary.dto"; +import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto"; +import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; import { AcceptIntakeDto, ApproveConsolidationDto, @@ -80,26 +82,26 @@ import { RequestOperationDto, OperationReviewDto, StaffRejectDto, -} from './dto/request-changes.dto'; -import { ContractViewDto } from './dto/contract-view.dto'; -import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; -import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; -import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; -import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; -import { CustomerTruckService } from './customer-truck.service'; -import { FirstMileService } from '../first-mile/first-mile.service'; -import { LastMileService } from '../last-mile/last-mile.service'; -import { GenerateGrnDto } from './dto/generate-grn.dto'; -import { ContainerReceiptService } from './container-receipt.service'; -import { SignContractDto } from './dto/sign-contract.dto'; -import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto'; -import { UpdateBookingDto } from './dto/update-booking.dto'; -import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; +} from "./dto/request-changes.dto"; +import { ContractViewDto } from "./dto/contract-view.dto"; +import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto"; +import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto"; +import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto"; +import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto"; +import { CustomerTruckService } from "./customer-truck.service"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { LastMileService } from "../last-mile/last-mile.service"; +import { GenerateGrnDto } from "./dto/generate-grn.dto"; +import { ContainerReceiptService } from "./container-receipt.service"; +import { SignContractDto } from "./dto/sign-contract.dto"; +import { SetExportHandoverModeDto } from "./dto/set-export-handover-mode.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; +import { BookingWagonCancellationService } from "./booking-wagon-cancellation.service"; import { FilterWagonCancellationsDto, RebookCancelledWagonsDto, RequestWagonCancellationDto, -} from './dto/wagon-cancellation.dto'; +} from "./dto/wagon-cancellation.dto"; import { type AuthUserPayload, resolveAuthUserId, @@ -136,7 +138,7 @@ function summarizeMileLeg(rec?: Record): MileLegSummary | null { rec.vehicle?.currency ?? assignments[0]?.vehicle?.currency ?? rec.booking?.paymentCurrency ?? - 'ETB'; + "ETB"; const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ plate: a.vehicle?.plateNumber ?? null, code: a.vehicle?.code ?? null, @@ -154,7 +156,7 @@ function summarizeMileLeg(rec?: Record): MileLegSummary | null { }); } return { - status: rec.status ?? '', + status: rec.status ?? "", exactKm: num(rec.exactKm), remainingPayment: num(rec.remainingPayment), currency, @@ -415,14 +417,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/available-days') + @Get(":id/available-days") @MixedAudience([]) @ApiOperation({ summary: - 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)', + "Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)", }) async availableDays( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -438,17 +440,17 @@ export class BookingsController { return this.bookingsService.availableDaysForBooking(id); } - @Get(':id/day-availability') + @Get(":id/day-availability") @MixedAudience([]) @ApiOperation({ summary: - 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + - 'Export: whole-booking fit + largest single-train leftover. ' + - 'Import/domestic: total room across the day for the booking\'s wagon type.', + "Advisory free-wagon count for a shipment day (planning hint, not enforced). " + + "Export: whole-booking fit + largest single-train leftover. " + + "Import/domestic: total room across the day for the booking's wagon type.", }) async dayAvailability( - @Param('id', ParseUUIDPipe) id: string, - @Query('date') date: string, + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -464,13 +466,14 @@ export class BookingsController { return this.transitionService.dayAvailabilityForBooking(id, date); } - @Get(':id/mile-summary') + @Get(":id/mile-summary") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ - summary: 'First/last-mile operational summary for a booking (customer-safe)', + summary: + "First/last-mile operational summary for a booking (customer-safe)", }) async mileSummary( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // Customers may only see their own booking's mile summary. @@ -479,7 +482,10 @@ export class BookingsController { !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) ) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const [first, last] = await Promise.all([ @@ -492,82 +498,100 @@ export class BookingsController { }; } - @Post(':id/customer-truck-assignment') + @Post(":id/customer-truck-assignment") @PortalCustomer() - @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) + @ApiOperation({ + summary: "Customer assigns external truck and driver for terminal pickup", + }) async assignCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CustomerTruckAssignmentDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const assigned = await this.bookingsService.assignCustomerTruck(id, dto); return this.transitionService.enrichBookingResponse(assigned); } - @Get(':id/customer-truck-assignment/freight-order') + @Get(":id/customer-truck-assignment/freight-order") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: - 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', + "Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).", }) async customerTruckFreightOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, - @Query('copies') copies?: string, + @Query("copies") copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - const extraCopyIndexes = (copies ?? '') - .split(',') + const extraCopyIndexes = (copies ?? "") + .split(",") .map((n) => Number(n.trim())) .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = - await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await this.bookingsService.customerTruckFreightOrderCopies( + id, + extraCopyIndexes, + ); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.send(buffer); } - @Get(':id/carriage-acceptance-sheet') + @Get(":id/carriage-acceptance-sheet") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: - 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', + "Download the carriage acceptance sheet (one per booking, lists every allocated wagon)", }) async carriageAcceptanceSheet( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id); - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + const { filename, buffer } = + await this.bookingsService.carriageAcceptanceSheet(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.send(buffer); } - @Get(':id/wagons') + @Get(":id/wagons") @ApiOperation({ summary: - 'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train', + "Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train", }) async wagonAllocations( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.bookingsService.wagonAllocations(id); } @@ -576,41 +600,52 @@ export class BookingsController { // Customer endpoints are ownership-scoped (no portal permission keys); the // staff history/void/rebook variants are permission-gated below. - @Post(':id/wagon-cancellations/preview') - @ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' }) + @Post(":id/wagon-cancellations/preview") + @ApiOperation({ + summary: + "Preview the fee/credit of a partial wagon cancellation (no writes)", + }) async previewWagonCancellation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestWagonCancellationDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.previewCancellation(id, dto); } - @Post(':id/wagon-cancellations') + @Post(":id/wagon-cancellations") @ApiOperation({ summary: - 'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles', + "Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", }) async requestWagonCancellation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestWagonCancellationDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.requestCancellation(id, dto, user?.id); } - @Get(':id/wagon-cancellations') - @ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' }) + @Get(":id/wagon-cancellations") + @ApiOperation({ + summary: "Wagon-cancellation history of one booking (owner or staff)", + }) async listBookingWagonCancellations( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -618,19 +653,28 @@ export class BookingsController { hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); if (!staff) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 }); } - @Get('wagon-cancellations/my') - @ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' }) + @Get("wagon-cancellations/my") + @ApiOperation({ + summary: + "Wagon-cancellation history of the calling customer (paginated, filterable)", + }) async listMyWagonCancellations( @Query() filter: FilterWagonCancellationsDto, @CurrentUser() user: TCurrentUser, ) { - const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? ''); - if (!companyId) throw new ForbiddenException('No customer company for this user.'); + const companyId = await this.bookingsService.resolveCustomerCompanyId( + user?.id ?? "", + ); + if (!companyId) + throw new ForbiddenException("No customer company for this user."); return this.wagonCancellationService.list({ companyId, status: filter.statuses, @@ -642,10 +686,14 @@ export class BookingsController { }); } - @Get('wagon-cancellations/history') + @Get("wagon-cancellations/history") @WagonCancellationView() - @ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' }) - async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) { + @ApiOperation({ + summary: "All wagon cancellations (staff, paginated, filterable)", + }) + async listAllWagonCancellations( + @Query() filter: FilterWagonCancellationsDto, + ) { return this.wagonCancellationService.list({ status: filter.statuses, search: filter.search, @@ -656,29 +704,34 @@ export class BookingsController { }); } - @Post('wagon-cancellations/:cancellationId/withdraw') - @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.' }) + @Post("wagon-cancellations/:cancellationId/withdraw") + @ApiOperation({ + summary: + "Withdraw a fee-pending wagon cancellation — STAFF ONLY (void permission). A customer cancellation is final; only an admin can revert it.", + }) async withdrawWagonCancellation( - @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, @CurrentUser() user: TCurrentUser, ) { // Customer cancellations are irreversible from the portal — no owner // fallback here. Only staff holding the void permission can revert one. - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationVoid) + ) { throw new ForbiddenException( - 'A cancellation request cannot be withdrawn from the portal — contact EDR staff.', + "A cancellation request cannot be withdrawn from the portal — contact EDR staff.", ); } return this.wagonCancellationService.withdraw(cancellationId); } - @Post('wagon-cancellations/:cancellationId/rebook') + @Post("wagon-cancellations/:cancellationId/rebook") @ApiOperation({ summary: - 'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)', + "Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", }) async rebookWagonCancellation( - @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, @Body() dto: RebookCancelledWagonsDto, @CurrentUser() user: TCurrentUser, ) { @@ -699,180 +752,228 @@ export class BookingsController { if (hasFreightPermission(user, staffPermission)) return; const row = await this.wagonCancellationService.findById(cancellationId); const booking = await this.bookingsService.findById(row.bookingId); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } - @Get(':id/customer-trucks') + @Get(":id/customer-trucks") @MixedAudience([ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.warehouseInventory.view, ]) - @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) + @ApiOperation({ + summary: "List customer self-haul trucks (multi-truck) for a booking", + }) async listCustomerTrucks( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.listTrucks(id); } - @Post(':id/customer-trucks') + @Post(":id/customer-trucks") @PortalCustomer() - @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) + @ApiOperation({ + summary: + "Add a customer self-haul truck carrying 1–2 of the booking containers", + }) async addCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AddCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.addTruck(id, dto); } - @Post(':id/customer-trucks/bulk') + @Post(":id/customer-trucks/bulk") @PortalCustomer() - @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + @ApiOperation({ + summary: "Bulk add customer trucks from array payload (Excel parsed)", + }) async bulkAddCustomerTrucks( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() payload: { trucks: AddCustomerTruckDto[] }, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.addBulkTrucks(id, payload.trucks); } - @Patch(':id/customer-trucks/:assignmentId') + @Patch(":id/customer-trucks/:assignmentId") @PortalCustomer() - @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) + @ApiOperation({ + summary: + "Edit a not-yet-arrived customer truck (plate/driver/type + containers)", + }) async updateCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: AddCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.updateTruck(id, assignmentId, dto); } - @Delete(':id/customer-trucks/:assignmentId') + @Delete(":id/customer-trucks/:assignmentId") @PortalCustomer() - @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) + @ApiOperation({ + summary: "Remove a not-yet-arrived customer truck from a booking", + }) async removeCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.removeTruck(id, assignmentId); } - @Get(':id/customer-trucks/loadable-containers') + @Get(":id/customer-trucks/loadable-containers") @MixedAudience(FREIGHT_PERMS.bookings.view) - @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) + @ApiOperation({ summary: "Booking containers not yet loaded onto a truck" }) async loadableContainers( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } return this.customerTruckService.getLoadableContainers(id); } - @Patch(':id/export-handover-mode') + @Patch(":id/export-handover-mode") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ - summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first', + summary: + "Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", }) setExportHandoverMode( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SetExportHandoverModeDto, ) { - return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode); + return this.bookingsService.setExportHandoverMode( + id, + dto.exportHandoverMode, + ); } - @Post(':id/customer-trucks/:assignmentId/load') + @Post(":id/customer-trucks/:assignmentId/load") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) + @ApiOperation({ + summary: "Truck_dispatch: load selected containers onto a truck (staff)", + }) async loadCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: LoadCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can load a truck'); + throw new ForbiddenException("Only warehouse staff can load a truck"); } return this.customerTruckService.loadTruck(id, assignmentId, dto); } - @Post(':id/customer-trucks/:assignmentId/depart') + @Post(":id/customer-trucks/:assignmentId/depart") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ - summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + summary: + "Register an import truck leaving: containers loaded + weighed gross (staff)", }) async departCustomerTruck( - @Param('id', ParseUUIDPipe) id: string, - @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("assignmentId", ParseUUIDPipe) assignmentId: string, @Body() dto: DepartCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { // Weighing + registering the load on exit is a warehouse/gate staff action. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can register a truck departure'); + throw new ForbiddenException( + "Only warehouse staff can register a truck departure", + ); } return this.customerTruckService.departTruck(id, assignmentId, dto); } - @Get(':id/received-pending-grn') + @Get(":id/received-pending-grn") @MixedAudience(FREIGHT_PERMS.bookings.view) - @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + @ApiOperation({ + summary: "Containers received into port but not yet on a GRN", + }) async receivedPendingGrn( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // GRN is a warehouse-staff action — no customer access. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + throw new ForbiddenException( + "Only warehouse staff can view or generate GRNs", + ); } return this.containerReceiptService.listReceivedPendingGrn(id); } - @Post(':id/generate-grn') + @Post(":id/generate-grn") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + "Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", }) async generateGrn( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: GenerateGrnDto, @CurrentUser() user: TCurrentUser, ) { // GRN is a warehouse-staff action — no customer access. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + throw new ForbiddenException( + "Only warehouse staff can view or generate GRNs", + ); } return this.containerReceiptService.generateGrn(id, dto.containerNumbers); } - @Get(':id/tracking') + @Get(":id/tracking") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Shipment tracking timeline for a booking", @@ -969,22 +1070,29 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── - @Get('clearance/et-queue') + @Get("clearance/et-queue") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' }) + @ApiOperation({ + summary: "GL ET queue — general customs bookings awaiting ET action", + }) getBookingEtClearanceQueue(@CurrentUser() user: unknown) { return this.bookingClearanceService.etQueue(user); } - @Get('clearance/dj-queue') + @Get("clearance/dj-queue") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' }) + @ApiOperation({ + summary: "GL DJ queue — general customs bookings awaiting DJ action", + }) getBookingDjClearanceQueue() { return this.bookingClearanceService.djQueue(); } - @Get(':id/clearance') - @MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments]) + @Get(":id/clearance") + @MixedAudience([ + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.bookings.reviewDocuments, + ]) @ApiOperation({ summary: "Document-clearance grid (required docs + upload + GL review status)", @@ -1064,7 +1172,10 @@ export class BookingsController { : undefined, cargoTypeId: cargoTypeId || undefined, cargoTypeCode: cargoTypeCode || undefined, - wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined, + wagons: + Number.isFinite(parsedWagons) && parsedWagons > 0 + ? parsedWagons + : undefined, }); } @@ -1161,7 +1272,10 @@ export class BookingsController { hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions); if (isStaff) return this.clearanceChargeService.list(id); const booking = await this.bookingsService.findById(id); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); return this.clearanceChargeService.listForCustomer(id); } @@ -1208,7 +1322,8 @@ export class BookingsController { @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document", + summary: + "GL Djibouti uploads (or replaces, until billed) the port-charges document", }) uploadPortChargeDocument( @Param("id", ParseUUIDPipe) id: string, @@ -1288,15 +1403,23 @@ export class BookingsController { @Get(":id/additional-charges") @MixedAudience(FREIGHT_PERMS.additionalCharges.view) - @ApiOperation({ summary: "Ad-hoc extra charges finance has raised against this booking" }) + @ApiOperation({ + summary: "Ad-hoc extra charges finance has raised against this booking", + }) async getAdditionalCharges( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.additionalCharges.view); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.additionalCharges.view, + ); if (!isStaff) { const booking = await this.bookingsService.findById(id); - await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); } const charges = await this.additionalChargeService.list(id); // A charge finance hasn't sent yet isn't the customer's to see. @@ -1308,7 +1431,8 @@ export class BookingsController { @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: "Finance raises a new additional charge — draft, or send to the customer immediately", + summary: + "Finance raises a new additional charge — draft, or send to the customer immediately", }) createAdditionalCharge( @Param("id", ParseUUIDPipe) id: string, @@ -1316,18 +1440,29 @@ export class BookingsController { @Body() dto: CreateAdditionalChargeDto, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.create(id, dto, resolveAuthUserId(user), file); + return this.additionalChargeService.create( + id, + dto, + resolveAuthUserId(user), + file, + ); } @Post(":id/additional-charges/:chargeId/send") @BookingStaff(FREIGHT_PERMS.additionalCharges.send) - @ApiOperation({ summary: "Issue the draft charge's payable invoice and notify the customer" }) + @ApiOperation({ + summary: "Issue the draft charge's payable invoice and notify the customer", + }) sendAdditionalCharge( @Param("id", ParseUUIDPipe) id: string, @Param("chargeId", ParseUUIDPipe) chargeId: string, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.send(id, chargeId, resolveAuthUserId(user)); + return this.additionalChargeService.send( + id, + chargeId, + resolveAuthUserId(user), + ); } @Post(":id/additional-charges/:chargeId/cancel") @@ -1339,7 +1474,12 @@ export class BookingsController { @Body() dto: CancelAdditionalChargeDto, @CurrentUser() user: TCurrentUser, ) { - return this.additionalChargeService.cancel(id, chargeId, resolveAuthUserId(user), dto.reason); + return this.additionalChargeService.cancel( + id, + chargeId, + resolveAuthUserId(user), + dto.reason, + ); } @Post(":id/clearance/output-documents") @@ -1377,15 +1517,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-assignee/request') + @Post(":id/clearance/transit-assignee/request") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: - 'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration', + "GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", }) async requestBookingTransitAssignee( - @Param('id', ParseUUIDPipe) id: string, - @Body('note') note: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("note") note: string | undefined, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.requestTransitAssignee( @@ -1396,15 +1536,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-assignee/assign') + @Post(":id/clearance/transit-assignee/assign") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: - 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', + "GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", }) async assignBookingTransitAssignee( - @Param('id', ParseUUIDPipe) id: string, - @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, + @Param("id", ParseUUIDPipe) id: string, + @Body("transitAgentId", ParseUUIDPipe) transitAgentId: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.assignTransitAssignee( @@ -1415,13 +1555,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/declaration') + @Post(":id/clearance/declaration") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "GL ET uploads customs declaration on booking (GENERAL customs)", + }) async uploadBookingDeclaration( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1433,26 +1575,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/duty') + @Post(":id/clearance/duty") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) - @UseInterceptors(FileInterceptor('attachment')) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' }) + @UseInterceptors(FileInterceptor("attachment")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "GL ET sets duty/tax on booking with notice attachment", + }) async adviseBookingDuty( - @Param('id', ParseUUIDPipe) id: string, - @Body('dutyRequired') dutyRequiredRaw: string, - @Body('amount') amountRaw: string | undefined, - @Body('currency') currency: string | undefined, - @Body('declarationSerial') declarationSerial: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("dutyRequired") dutyRequiredRaw: string, + @Body("amount") amountRaw: string | undefined, + @Body("currency") currency: string | undefined, + @Body("declarationSerial") declarationSerial: string | undefined, @UploadedFile() attachment: Express.Multer.File | undefined, @CurrentUser() user: TCurrentUser, ) { - const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dutyRequired = dutyRequiredRaw === "true" || dutyRequiredRaw === "1"; const dto: AdviseContractDutyDto = { dutyRequired, amount: - amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, - currency: currency ?? 'ETB', + amountRaw != null && amountRaw !== "" ? Number(amountRaw) : undefined, + currency: currency ?? "ETB", declarationSerial, }; const booking = await this.bookingClearanceService.adviseDuty( @@ -1464,18 +1608,18 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration') + @Post(":id/clearance/draft-declaration") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - 'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review', + "GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", }) async uploadBookingDraftDeclaration( - @Param('id', ParseUUIDPipe) id: string, - @Body('price') priceRaw: string, - @Body('currency') currency: string | undefined, + @Param("id", ParseUUIDPipe) id: string, + @Body("price") priceRaw: string, + @Body("currency") currency: string | undefined, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1483,20 +1627,20 @@ export class BookingsController { id, files ?? [], Number(priceRaw), - currency ?? 'ETB', + currency ?? "ETB", resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration/accept') + @Post(":id/clearance/draft-declaration/accept") @PortalCustomer() @ApiOperation({ summary: - 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', + "Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", }) async acceptBookingDraftDeclaration( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.acceptDraftDeclaration( @@ -1506,30 +1650,31 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/draft-declaration/change') + @Post(":id/clearance/draft-declaration/change") @PortalCustomer() @ApiOperation({ summary: - 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', + "Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", }) async requestBookingDraftDeclarationChange( - @Param('id', ParseUUIDPipe) id: string, - @Body('note') note: string, + @Param("id", ParseUUIDPipe) id: string, + @Body("note") note: string, @CurrentUser() user: TCurrentUser, ) { - const booking = await this.bookingClearanceService.requestDraftDeclarationChange( - id, - note, - resolveAuthUserId(user), - ); + const booking = + await this.bookingClearanceService.requestDraftDeclarationChange( + id, + note, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize-pre-clearance') + @Post(":id/clearance/finalize-pre-clearance") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) + @ApiOperation({ summary: "GL ET finalizes import pre-clearance on booking" }) async finalizeBookingPreClearance( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.finalizePreClearance( @@ -1539,13 +1684,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/duty-slip') + @Post(":id/clearance/duty-slip") @PortalCustomer() - @UseInterceptors(FileInterceptor('file')) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) + @UseInterceptors(FileInterceptor("file")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Customer uploads duty/tax payment slip on booking", + }) async uploadBookingDutySlip( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: AuthUserPayload, ) { @@ -1557,12 +1704,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/transit-permit') + @Post(":id/clearance/transit-permit") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingTransitPermit( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { @@ -1574,15 +1721,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/delivery-order') + @Post(":id/clearance/delivery-order") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingDeliveryOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], - @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, - @Body('doCollectedDate') doCollectedDate: string | undefined, + @Body("vesselArrivalDate") vesselArrivalDate: string | undefined, + @Body("doCollectedDate") doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( @@ -1594,14 +1741,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/release-order') + @Post(":id/clearance/release-order") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") async uploadBookingReleaseOrder( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], - @Body('vesselDepartureDate') vesselDepartureDate: string, + @Body("vesselDepartureDate") vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { const result = await this.bookingClearanceService.uploadReleaseOrder( @@ -1617,10 +1764,10 @@ export class BookingsController { }; } - @Post(':id/clearance/ro-amendment') + @Post(":id/clearance/ro-amendment") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) async requestBookingRoAmendment( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RoAmendmentDto, @CurrentUser() user: TCurrentUser, ) { @@ -1632,10 +1779,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/export-release') + @Post(":id/clearance/export-release") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) async confirmBookingExportRelease( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.confirmExportRelease( @@ -1645,7 +1792,7 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/request-changes') + @Post(":id/staff/request-changes") @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( @@ -1857,12 +2004,31 @@ export class BookingsController { @Get("consolidation-approvals/queue") @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiQuery({ + name: "status", + required: false, + enum: ConsolidationApprovalStatus, + description: "Filter to one status. Omit for pending first, then decided.", + }) + @ApiQuery({ name: "page", required: false, type: Number }) + @ApiQuery({ name: "pageSize", required: false, type: Number }) @ApiOperation({ summary: - "Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.", + "One page of shared-wagon pairings: pending ones first (oldest first), then the decided history with who decided each. Every row covers BOTH bookings on the wagon.", }) - consolidationApprovalQueue() { - return this.consolidationApprovalService.queue(); + consolidationApprovalQueue( + @CurrentUser() user: AuthUserPayload, + @Query("status") status?: ConsolidationApprovalStatus, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + ) { + return this.consolidationApprovalService.queue({ + status, + page: page ? Number(page) : undefined, + pageSize: pageSize ? Number(pageSize) : undefined, + // Narrows to the yards the caller's desk is mapped to. + user, + }); } @Get(":id/consolidation-approvals") @@ -1890,6 +2056,7 @@ export class BookingsController { approvalId, resolveAuthUserId(user) ?? "", dto.note, + user, ); } @@ -1908,6 +2075,7 @@ export class BookingsController { approvalId, resolveAuthUserId(user) ?? "", dto.reason, + user, ); } @@ -1922,12 +2090,13 @@ export class BookingsController { @Body() dto: PairedDecisionDto, @CurrentUser() user: AuthUserPayload, ) { - const { booking, partner } = await this.transitionService.applyPairedDecision( - id, - dto.decision, - resolveAuthUserId(user), - { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, - ); + const { booking, partner } = + await this.transitionService.applyPairedDecision( + id, + dto.decision, + resolveAuthUserId(user), + { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, + ); // Sequential enrichment: both go back so the UI can refresh either tab. const enrichedBooking = await this.transitionService.enrichBookingResponse(booking); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts index 5f121f630..971f1d831 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts @@ -1,9 +1,9 @@ import { ConsolidationApprovalService, CONSOLIDATION_APPROVAL_PENDING, -} from './consolidation-approval.service'; -import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity'; -import { Booking } from './entities/booking.entity'; +} from "./consolidation-approval.service"; +import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity"; +import { Booking } from "./entities/booking.entity"; /** * The shared-wagon approval gate. Two customers' cargo on one wagon is a @@ -14,37 +14,53 @@ import { Booking } from './entities/booking.entity'; * decision on one side of a shared wagon is meaningless without the other), and * a decided pairing cannot be decided twice. */ -describe('ConsolidationApprovalService', () => { +describe("ConsolidationApprovalService", () => { const PENDING = { - id: 'ap-1', - bookingId: 'b-1', - partnerBookingId: 'b-2', + id: "ap-1", + bookingId: "b-1", + partnerBookingId: "b-2", status: ConsolidationApprovalStatus.Pending, - requestedBy: 'gl-user', + requestedBy: "gl-user", }; - function makeService(overrides: { - approvals?: Partial>; - bookingsRepository?: Partial>; - } = {}) { + function makeService( + overrides: { + approvals?: Partial>; + bookingsRepository?: Partial>; + bookingsService?: Partial>; + /** Yard ids the caller is scoped to; null = unrestricted. */ + yardScope?: string[] | null; + } = {}, + ) { const approvals = { findPendingForBooking: jest.fn().mockResolvedValue(null), findById: jest.fn().mockResolvedValue(PENDING), - create: jest.fn().mockResolvedValue({ id: 'ap-1' }), + create: jest.fn().mockResolvedValue({ id: "ap-1" }), decide: jest.fn().mockResolvedValue(true), - findQueue: jest.fn().mockResolvedValue([]), + findQueuePage: jest.fn().mockResolvedValue({ items: [], total: 0 }), + countByStatus: jest + .fn() + .mockResolvedValue({ PENDING: 2, APPROVED: 4, REJECTED: 1 }), findAllForBooking: jest.fn().mockResolvedValue([]), ...overrides.approvals, }; const bookingsRepository = { update: jest.fn().mockResolvedValue(undefined), createReviewNote: jest.fn().mockResolvedValue(undefined), + resolveStaffNames: jest.fn().mockResolvedValue(new Map()), ...overrides.bookingsRepository, }; const bookingsService = { - findById: jest.fn(async (id: string) => - ({ id, reference: `BK-${id}` }) as Booking, + findById: jest.fn( + async (id: string) => + ({ + id, + reference: `BK-${id}`, + originYardId: "mojo", + destinationYardId: "djibouti", + }) as Booking, ), + ...overrides.bookingsService, }; const notifier = { consolidationApprovalRequestedToStaff: jest.fn(), @@ -55,6 +71,11 @@ describe('ConsolidationApprovalService', () => { const dataSource = { transaction: jest.fn(async (cb: () => Promise) => cb()), }; + const yardScope = { + getScopedYardIds: jest + .fn() + .mockResolvedValue(overrides.yardScope ?? null), + }; const service = new ConsolidationApprovalService( approvals as never, @@ -62,27 +83,28 @@ describe('ConsolidationApprovalService', () => { bookingsService as never, notifier as never, dataSource as never, + yardScope as never, ); - return { service, approvals, bookingsRepository, notifier }; + return { service, approvals, bookingsRepository, notifier, yardScope }; } - it('holds BOTH halves at the gate when a pairing is created', async () => { + it("holds BOTH halves at the gate when a pairing is created", async () => { const { service, approvals, bookingsRepository, notifier } = makeService(); - await service.requestApproval('b-1', 'b-2', 'gl-user'); + await service.requestApproval("b-1", "b-2", "gl-user"); expect(approvals.create).toHaveBeenCalledWith( expect.objectContaining({ - bookingId: 'b-1', - partnerBookingId: 'b-2', - requestedBy: 'gl-user', + bookingId: "b-1", + partnerBookingId: "b-2", + requestedBy: "gl-user", }), ); // Neither half may sit in the operations queue while the wagon is unreviewed. - expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { status: CONSOLIDATION_APPROVAL_PENDING, }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { status: CONSOLIDATION_APPROVAL_PENDING, }); expect( @@ -90,94 +112,102 @@ describe('ConsolidationApprovalService', () => { ).toHaveBeenCalledTimes(1); }); - it('does not open a second review for a pairing already pending', async () => { + it("does not open a second review for a pairing already pending", async () => { const { service, approvals } = makeService({ approvals: { findPendingForBooking: jest.fn().mockResolvedValue(PENDING), }, }); - const result = await service.requestApproval('b-1', 'b-2', 'gl-user'); + const result = await service.requestApproval("b-1", "b-2", "gl-user"); expect(result).toBe(PENDING); expect(approvals.create).not.toHaveBeenCalled(); }); - it('releases BOTH halves to Operations on approval, logging who decided', async () => { + it("releases BOTH halves to Operations on approval, logging who decided", async () => { const { service, approvals, bookingsRepository, notifier } = makeService(); - await service.approve('ap-1', 'approver-1', 'looks fine'); + await service.approve("ap-1", "approver-1", "looks fine"); expect(approvals.decide).toHaveBeenCalledWith( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Approved, - 'approver-1', - 'looks fine', + "approver-1", + "looks fine", + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { - status: 'OPERATION_REQUEST_PENDING', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_REQUEST_PENDING", }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { - status: 'OPERATION_REQUEST_PENDING', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { + status: "OPERATION_REQUEST_PENDING", }); // Operations only learns about the pair now — the gate is what kept it out. expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2); }); - it('sends BOTH halves back to GL on rejection, with the reason on each', async () => { + it("sends BOTH halves back to GL on rejection, with the reason on each", async () => { const { service, approvals, bookingsRepository } = makeService(); - await service.reject('ap-1', 'approver-1', 'partner cargo is wrong'); + await service.reject("ap-1", "approver-1", "partner cargo is wrong"); expect(approvals.decide).toHaveBeenCalledWith( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Rejected, - 'approver-1', - 'partner cargo is wrong', + "approver-1", + "partner cargo is wrong", ); expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( - 'b-1', - 'partner cargo is wrong', - 'CHANGES_REQUESTED', + "b-1", + "partner cargo is wrong", + "CHANGES_REQUESTED", ); expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( - 'b-2', - 'partner cargo is wrong', - 'CHANGES_REQUESTED', + "b-2", + "partner cargo is wrong", + "CHANGES_REQUESTED", ); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { - status: 'OPERATION_CHANGES_REQUESTED', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_CHANGES_REQUESTED", }); - expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { - status: 'OPERATION_CHANGES_REQUESTED', + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { + status: "OPERATION_CHANGES_REQUESTED", }); }); - it('lets the requester approve their own pairing', async () => { + it("lets the requester approve their own pairing", async () => { // No maker-checker separation: the permission alone decides who may approve, // and the audit trail still records requester and approver separately. const { service, approvals } = makeService(); - await service.approve('ap-1', 'gl-user'); + await service.approve("ap-1", "gl-user"); expect(approvals.decide).toHaveBeenCalledWith( - 'ap-1', + "ap-1", ConsolidationApprovalStatus.Approved, - 'gl-user', + "gl-user", undefined, + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); }); - it('requires a reason to reject', async () => { + it("requires a reason to reject", async () => { const { service, approvals } = makeService(); - await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow( + await expect(service.reject("ap-1", "approver-1", " ")).rejects.toThrow( /reason is required/i, ); expect(approvals.decide).not.toHaveBeenCalled(); }); - it('refuses a pairing that was already decided', async () => { + it("refuses a pairing that was already decided", async () => { const { service, bookingsRepository } = makeService({ approvals: { findById: jest.fn().mockResolvedValue({ @@ -187,21 +217,203 @@ describe('ConsolidationApprovalService', () => { }, }); - await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow( + await expect(service.approve("ap-1", "approver-1")).rejects.toThrow( /already approved/i, ); expect(bookingsRepository.update).not.toHaveBeenCalled(); }); - it('loses cleanly when another approver decides the same pairing first', async () => { + it("loses cleanly when another approver decides the same pairing first", async () => { // decide() writes only against a still-PENDING row, so the loser of the race // affects nothing and must not move the bookings. const { service } = makeService({ approvals: { decide: jest.fn().mockResolvedValue(false) }, }); - await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow( + await expect(service.approve("ap-1", "approver-1")).rejects.toThrow( /already decided by someone else/i, ); }); + + it("approves a pairing that was rejected earlier, releasing both halves", async () => { + // A rejection is not final: the reviewer may change their mind, or GL may + // argue the case. Only an already-approved pairing is closed. + const { service, bookingsRepository } = makeService({ + approvals: { + findById: jest.fn().mockResolvedValue({ + ...PENDING, + status: ConsolidationApprovalStatus.Rejected, + decidedBy: "approver-1", + }), + }, + }); + + await service.approve("ap-1", "approver-2", "resolved with GL"); + + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_REQUEST_PENDING", + }); + expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", { + status: "OPERATION_REQUEST_PENDING", + }); + }); + + it("refuses to reject a pairing that was already rejected", async () => { + const { service, bookingsRepository } = makeService({ + approvals: { + findById: jest.fn().mockResolvedValue({ + ...PENDING, + status: ConsolidationApprovalStatus.Rejected, + }), + }, + }); + + await expect( + service.reject("ap-1", "approver-1", "still wrong"), + ).rejects.toThrow(/already rejected/i); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it("names the requester and the decider on every queue row", async () => { + // The stored ids mean nothing to a reviewer reading the history. + const { service } = makeService({ + approvals: { + findQueuePage: jest.fn().mockResolvedValue({ + items: [ + { + ...PENDING, + status: ConsolidationApprovalStatus.Approved, + decidedBy: "approver-1", + }, + ], + total: 1, + }), + }, + bookingsRepository: { + resolveStaffNames: jest.fn().mockResolvedValue( + new Map([ + ["gl-user", "Selam GL"], + ["approver-1", "Abebe Approver"], + ]), + ), + }, + }); + + const { items, meta, counts } = await service.queue({ pageSize: 10 }); + + expect(items[0].requestedByName).toBe("Selam GL"); + expect(items[0].decidedByName).toBe("Abebe Approver"); + // Badges count the whole queue, not the page that happened to load. + expect(counts.APPROVED).toBe(4); + expect(meta).toMatchObject({ + page: 1, + pageSize: 10, + total: 1, + totalPages: 1, + }); + }); + + it("pages the queue in SQL and reports the page meta", async () => { + // The page must be cut in the query, not sliced out of a full fetch — + // otherwise ordering only holds within whatever page loaded. + const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 25 }); + const { service } = makeService({ approvals: { findQueuePage } }); + + const { meta } = await service.queue({ + status: ConsolidationApprovalStatus.Rejected, + page: 2, + pageSize: 10, + }); + + expect(findQueuePage).toHaveBeenCalledWith({ + status: ConsolidationApprovalStatus.Rejected, + page: 2, + pageSize: 10, + }); + expect(meta).toMatchObject({ + page: 2, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + }); + + it("narrows the queue and the badges to the caller's yards", async () => { + // A Mojo + Adama desk sees both yards' pairings, and nothing else. The + // badges must be narrowed too, or they promise rows the caller cannot open. + const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 }); + const countByStatus = jest + .fn() + .mockResolvedValue({ PENDING: 1, APPROVED: 0, REJECTED: 0 }); + const { service } = makeService({ + approvals: { findQueuePage, countByStatus }, + yardScope: ["mojo", "adama"], + }); + + await service.queue({ user: { id: "u-1" }, page: 1, pageSize: 10 }); + + expect(findQueuePage).toHaveBeenCalledWith( + expect.objectContaining({ yardIds: ["mojo", "adama"] }), + ); + expect(countByStatus).toHaveBeenCalledWith(["mojo", "adama"]); + }); + + it("leaves the queue unnarrowed for an unrestricted caller", async () => { + // Super admin, `yards:view_all`, or a desk with no yard mapping at all — + // the mapping narrows access, it never grants it. + const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 }); + const { service } = makeService({ + approvals: { findQueuePage }, + yardScope: null, + }); + + await service.queue({ user: { id: "u-1" } }); + + expect(findQueuePage).toHaveBeenCalledWith( + expect.objectContaining({ yardIds: undefined }), + ); + }); + + it("refuses to decide a pairing outside the caller's yards", async () => { + // Hiding the row is not enough — the id is guessable from a shared link, + // and deciding moves two other yards' bookings. + const { service, bookingsRepository } = makeService({ + yardScope: ["adama"], + }); + + await expect( + service.approve("ap-1", "approver-1", undefined, { id: "u-1" }), + ).rejects.toThrow(/outside your assigned yards/i); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it("allows a decision when only the PARTNER half touches the caller's yard", async () => { + // The pair is one decision, so seeing one side is seeing the pairing. + const { service, bookingsRepository } = makeService({ + yardScope: ["dire-dawa"], + bookingsService: { + findById: jest.fn(async (id: string) => + id === "b-2" + ? ({ + id, + reference: "BK-b-2", + originYardId: "djibouti", + destinationYardId: "dire-dawa", + } as Booking) + : ({ + id, + reference: "BK-b-1", + originYardId: "mojo", + destinationYardId: "djibouti", + } as Booking), + ), + }, + }); + + await service.approve("ap-1", "approver-1", undefined, { id: "u-1" }); + + expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", { + status: "OPERATION_REQUEST_PENDING", + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts index ab4f4891d..9851b86ff 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + ForbiddenException, Inject, Injectable, Logger, @@ -18,6 +19,7 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo import { BookingsRepository } from "./bookings.repository"; import { BookingsService } from "./bookings.service"; import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service"; +import { YardScopeService } from "../rule-engine/services/yard-scope.service"; /** Where a rejected pair goes back to, so GL can fix and resubmit. */ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; @@ -25,6 +27,12 @@ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; /** The gate's own holding status — neither half reaches Operations from here. */ export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING"; +/** An approval row with the requester's and decider's names resolved. */ +export type ConsolidationApprovalView = ConsolidationApproval & { + requestedByName: string | null; + decidedByName: string | null; +}; + /** * The shared-wagon approval gate. * @@ -53,6 +61,7 @@ export class ConsolidationApprovalService { private readonly bookingsService: BookingsService, private readonly notifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, + private readonly yardScope: YardScopeService, ) {} /** @@ -116,8 +125,16 @@ export class ConsolidationApprovalService { approvalId: string, decidedBy: string, note?: string, + user?: unknown, ): Promise<{ booking: Booking; partner: Booking }> { - const approval = await this.loadPending(approvalId); + // A pairing that was rejected can still be approved later — the reviewer + // changed their mind, or GL argued the case. Only an already-approved one + // is final, since both halves have moved on to Operations by then. + const approval = await this.loadDecidable(approvalId, [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ]); + await this.assertInScope(approval, user); await this.dataSource.transaction(async () => { const claimed = await this.approvals.decide( @@ -125,6 +142,10 @@ export class ConsolidationApprovalService { ConsolidationApprovalStatus.Approved, decidedBy, note, + [ + ConsolidationApprovalStatus.Pending, + ConsolidationApprovalStatus.Rejected, + ], ); // Lost the race to another approver deciding the same pairing. if (!claimed) { @@ -162,13 +183,17 @@ export class ConsolidationApprovalService { approvalId: string, decidedBy: string, reason: string, + user?: unknown, ): Promise<{ booking: Booking; partner: Booking }> { if (!reason?.trim()) { throw new BadRequestException( "A reason is required to reject a consolidation.", ); } - const approval = await this.loadPending(approvalId); + const approval = await this.loadDecidable(approvalId, [ + ConsolidationApprovalStatus.Pending, + ]); + await this.assertInScope(approval, user); await this.dataSource.transaction(async () => { const claimed = await this.approvals.decide( @@ -212,9 +237,88 @@ export class ConsolidationApprovalService { return { booking, partner }; } - /** Pending pairings awaiting a decision, oldest first. */ - queue(): Promise { - return this.approvals.findQueue(); + /** + * One page of the review queue, or of its history: pending pairings first, + * then the decided ones, each carrying the display name of whoever requested + * and whoever decided it — the stored ids tell a reviewer nothing. + * + * `user` narrows the whole thing to the caller's yards: a Mojo desk sees the + * pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees + * both yards' pairings. The counts behind the tabs are narrowed the same way, + * so a badge never promises rows the caller cannot open. + */ + async queue(options?: { + status?: ConsolidationApprovalStatus; + page?: number; + pageSize?: number; + /** The `/auth/me` caller. Omit only for internal, unscoped reads. */ + user?: unknown; + }): Promise<{ + items: ConsolidationApprovalView[]; + total: number; + /** Counts per status within the caller's scope — the tab badges. */ + counts: Record; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }> { + const page = Math.max(1, options?.page ?? 1); + const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10)); + const yardIds = await this.scopedYardIds(options?.user); + + const { items: rows, total } = await this.approvals.findQueuePage({ + status: options?.status, + yardIds, + page, + pageSize, + }); + const counts = await this.approvals.countByStatus(yardIds); + const names = await this.bookingsRepository.resolveStaffNames( + rows.flatMap((r) => [r.requestedBy, r.decidedBy]), + ); + const items = rows.map((row) => ({ + ...row, + requestedByName: row.requestedBy + ? (names.get(row.requestedBy) ?? null) + : null, + decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null, + })); + + const totalPages = Math.ceil(total / pageSize); + return { + items, + total, + counts, + meta: { + page, + pageSize, + total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }; + } + + /** + * Yard ids the caller may see, or undefined for unrestricted. + * + * Scope comes from the desk they are logged in as: `yard_positions` maps a + * position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a + * `yards:view_all` holder, and a desk with NO yard mapping all resolve to + * unrestricted — the mapping narrows access, it never grants it. + * + * Called with no user only from internal paths, which are unscoped. + */ + private async scopedYardIds(user: unknown): Promise { + if (!user) return undefined; + const scope = await this.yardScope.getScopedYardIds(user as never); + return scope ?? undefined; } /** Full decision history for one booking — who decided what, and when. */ @@ -227,12 +331,46 @@ export class ConsolidationApprovalService { return this.approvals.findPendingForBooking(bookingId); } - private async loadPending(approvalId: string): Promise { + /** + * Refuse a decision on a pairing outside the caller's yards. + * + * Hiding the row from the list is not enough on its own: the id is guessable + * from a shared link, and deciding a pairing moves two other yards' bookings. + * Same rule as the list — either half's origin or destination is enough. + */ + private async assertInScope( + approval: ConsolidationApproval, + user: unknown, + ): Promise { + const yardIds = await this.scopedYardIds(user); + if (!yardIds) return; + + const booking = await this.bookingsService.findById(approval.bookingId); + const partner = await this.bookingsService.findById( + approval.partnerBookingId, + ); + const touches = (b: Booking | null | undefined) => + !!b && + (yardIds.includes(b.originYardId) || + yardIds.includes(b.destinationYardId)); + + if (!touches(booking) && !touches(partner)) { + throw new ForbiddenException( + "This shared wagon is outside your assigned yards.", + ); + } + } + + /** Load a row and refuse it unless it is in one of the decidable states. */ + private async loadDecidable( + approvalId: string, + allowed: ConsolidationApprovalStatus[], + ): Promise { const approval = await this.approvals.findById(approvalId); if (!approval) { throw new NotFoundException(`Approval ${approvalId} not found`); } - if (approval.status !== ConsolidationApprovalStatus.Pending) { + if (!allowed.includes(approval.status)) { throw new ConflictException( `This consolidation was already ${approval.status.toLowerCase()}.`, ); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts index b398e7e8c..32d36d039 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts @@ -1,11 +1,41 @@ import { Injectable } from "@nestjs/common"; -import { DataSource, In, Repository } from "typeorm"; +import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm"; import { ConsolidationApproval, ConsolidationApprovalStatus, } from "./entities/consolidation-approval.entity"; +/** + * Narrow a queue query to the caller's yards. + * + * A shared wagon is visible when EITHER half of it starts or ends at one of + * those yards — the pairing is one decision, so seeing one side is seeing the + * pairing. Yards the train merely passes through do not count: only the two + * bookings' own endpoints do. + * + * `undefined` means unrestricted and adds no predicate. An EMPTY array means + * scoped-to-nothing and must match no rows — `IN ()` is not valid SQL, so it + * gets an explicit false instead of being skipped. + */ +function applyYardScope( + qb: SelectQueryBuilder, + yardIds: string[] | undefined, +): void { + if (!yardIds) return; + if (!yardIds.length) { + qb.andWhere("1 = 0"); + return; + } + qb.andWhere( + `(booking.originYardId IN (:...yardIds) + OR booking.destinationYardId IN (:...yardIds) + OR partnerBooking.originYardId IN (:...yardIds) + OR partnerBooking.destinationYardId IN (:...yardIds))`, + { yardIds }, + ); +} + /** * Persistence for the shared-wagon approval gate. Rows are never deleted — * decided rows are the audit trail of who approved which pairing and when. @@ -48,16 +78,91 @@ export class ConsolidationApprovalsRepository { return this.repository.findOne({ where: { id } }); } - /** Pending requests for the review queue, oldest first (FIFO). */ - findQueue(): Promise { - return this.repository.find({ - where: { status: ConsolidationApprovalStatus.Pending }, - relations: { - booking: { company: true }, - partnerBooking: { company: true }, - }, - order: { requestedAt: "ASC" }, - }); + /** + * One page of review-queue rows, with both bookings loaded. + * + * Pending rows are work still to do, so they come oldest first (FIFO) and + * ahead of everything else. Decided rows are history, so they come + * newest-decision-first. Ordering is done in SQL, not after the fact — a page + * sorted in memory would only be sorted within itself. + * + * `yardIds` narrows to the caller's yards (see YardScopeService); pass + * undefined for an unrestricted caller. The narrowing is a WHERE, not a + * post-filter, so the page and the total both count only visible rows. + */ + async findQueuePage(options: { + status?: ConsolidationApprovalStatus; + yardIds?: string[]; + page: number; + pageSize: number; + }): Promise<{ items: ConsolidationApproval[]; total: number }> { + const { status, yardIds, page, pageSize } = options; + const qb = this.repository + .createQueryBuilder("approval") + .leftJoinAndSelect("approval.booking", "booking") + .leftJoinAndSelect("booking.company", "company") + .leftJoinAndSelect("approval.partnerBooking", "partnerBooking") + .leftJoinAndSelect("partnerBooking.company", "partnerCompany"); + + if (status) { + qb.andWhere("approval.status = :status", { status }); + } else { + qb.addOrderBy( + `CASE WHEN approval.status = '${ConsolidationApprovalStatus.Pending}' THEN 0 ELSE 1 END`, + "ASC", + ); + } + + applyYardScope(qb, yardIds); + + // Pending has no decidedAt, decided rows all do — one pair of keys orders + // both groups correctly whichever tab asked. + const [items, total] = await qb + .addOrderBy("approval.decidedAt", "DESC", "NULLS FIRST") + .addOrderBy("approval.requestedAt", "ASC") + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + /** + * Row count per status, for the tab badges — those must show the whole + * queue, not just the page currently loaded. Narrowed by the same yard scope + * as the list, so a badge never promises rows the caller cannot open. + */ + async countByStatus( + yardIds?: string[], + ): Promise> { + const qb = this.repository + .createQueryBuilder("approval") + .select("approval.status", "status") + .addSelect("COUNT(*)", "count") + .groupBy("approval.status"); + + // The scope predicate reads both bookings, so it needs them joined even + // though the count itself selects no columns from them. + if (yardIds) { + qb.leftJoin("approval.booking", "booking").leftJoin( + "approval.partnerBooking", + "partnerBooking", + ); + } + applyYardScope(qb, yardIds); + + const rows = await qb.getRawMany<{ + status: ConsolidationApprovalStatus; + count: string; + }>(); + + const counts = { + [ConsolidationApprovalStatus.Pending]: 0, + [ConsolidationApprovalStatus.Approved]: 0, + [ConsolidationApprovalStatus.Rejected]: 0, + }; + for (const row of rows) counts[row.status] = Number(row.count); + return counts; } create(input: { @@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository { | ConsolidationApprovalStatus.Rejected, decidedBy: string | null, decisionNote?: string | null, + /** Statuses the row may be claimed FROM. Defaults to pending-only. */ + from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending], ): Promise { const result = await this.repository.update( - { id, status: ConsolidationApprovalStatus.Pending }, + { id, status: In(from) }, { status, decidedBy, @@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository { if (bookingIds.length === 0) return Promise.resolve([]); return this.repository.find({ where: [ - { bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending }, + { + bookingId: In(bookingIds), + status: ConsolidationApprovalStatus.Pending, + }, { partnerBookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending, diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index b0cbeb886..f4f25060b 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -18,6 +18,30 @@ export class TrainSchedulesRepository extends BaseRepository { return manager ? manager.getRepository(TrainSchedule) : this.repository; } + /** + * Slim consist view for read paths that only need the route stops, the + * built train, and slot→allocation existence (e.g. the schedule-yards tab): + * skips the booking/company/container branches of the full graph, which + * dominate its cost and go unused there. + */ + findByIdWithConsistLite(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relationLoadStrategy: 'query', + relations: { + route: { milestones: { yard: true } }, + trainSet: { + train: true, + locomotive: true, + locomotives: { locomotive: true }, + wagons: { allocations: true }, + }, + originStation: true, + destinationStation: true, + }, + }); + } + findByIdWithFullGraph(id: string, manager?: EntityManager): Promise { return this.repo(manager).findOne({ where: { id }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 6a3ace1fe..a86bc7651 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -1,6 +1,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import type { AuthUserPayload } from "../../../common/resolve-auth-user-id"; +import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto"; import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service"; import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; @@ -243,14 +244,27 @@ export class TrainSchedulingController { ); } + @Get("schedules/:id/phase") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Lightweight polling heartbeat: the schedule's status, booking-window phase and deadlines plus its updated_at — one row, no joins, so clients can poll cheaply and refetch the full detail only when something actually changed", + }) + getSchedulePhase(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getSchedulePhase(id); + } + @Get("schedules/:id/history") @TrainSchedulingView() @ApiOperation({ summary: "Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first", }) - getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.getScheduleHistory(id); + getScheduleHistory( + @Param("id", ParseUUIDPipe) id: string, + @Query() query: PaginationQueryDto, + ) { + return this.trainSchedulingService.getScheduleHistory(id, query); } @Get("bookable-schedules") 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 10e2b82bc..5ac6ed0bb 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 @@ -4379,7 +4379,10 @@ export class TrainSchedulingService { /** Log the train passing a station. Logging the destination station triggers arrival. */ async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + // Slim graph: checkpoint logging reads stops, locomotives, the built + // train and the wagon plans — never the booking/container branches. + // (arriveSchedule, invoked on the final leg, loads its own full graph.) + const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } @@ -4473,9 +4476,23 @@ export class TrainSchedulingService { const cutNow = Object.entries(cutPlan).filter(([, yardId]) => passedYardIds.includes(yardId), ); + // One fetch for the whole plan, one bulk insert per log table — the + // per-wagon UPDATEs stay (each patch differs) but the transaction no + // longer serializes a findOne + save pair per wagon. + const cutWagonById = new Map( + cutNow.length + ? ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(cutNow.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]) + : [], + ); + const adjustmentRows: ScheduleWagonAdjustmentLog[] = []; + const movementRows: WagonMovement[] = []; let realCutHappened = false; for (const [wagonId, cutYardId] of cutNow) { - const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + const wagon = cutWagonById.get(wagonId); // Already settled earlier (or re-pinned elsewhere) — not ours to move. if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue; if (realCutIds.has(wagonId) && builtTrainId) { @@ -4497,7 +4514,7 @@ export class TrainSchedulingService { AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`, [wagonId, builtTrainId], ); - await manager.getRepository(ScheduleWagonAdjustmentLog).save( + adjustmentRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, trainId: builtTrainId, @@ -4519,7 +4536,7 @@ export class TrainSchedulingService { status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, }); } - await manager.getRepository(WagonMovement).save( + movementRows.push( manager.getRepository(WagonMovement).create({ wagonId, fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId, @@ -4530,6 +4547,12 @@ export class TrainSchedulingService { }), ); } + if (adjustmentRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(adjustmentRows); + } + if (movementRows.length) { + await manager.getRepository(WagonMovement).save(movementRows); + } // Keep the coupling order gapless after permanent removals. if (realCutHappened && builtTrainId) { const remaining = await manager.getRepository(Wagon).find({ @@ -4557,8 +4580,16 @@ export class TrainSchedulingService { select: { id: true, sequenceNumber: true }, }); let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + const coupleWagonById = new Map( + ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(coupleNow.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]), + ); + const coupleLogRows: ScheduleWagonAdjustmentLog[] = []; for (const [wagonId, coupleYardId] of coupleNow) { - const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + const wagon = coupleWagonById.get(wagonId); if ( !wagon || wagon.trainId || @@ -4575,7 +4606,7 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId, }); - await manager.getRepository(ScheduleWagonAdjustmentLog).save( + coupleLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, trainId: builtTrainId, @@ -4588,6 +4619,9 @@ export class TrainSchedulingService { }), ); } + if (coupleLogRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows); + } } await manager .getRepository(Wagon) @@ -4798,11 +4832,23 @@ export class TrainSchedulingService { ); } + // One fetch for every pinned wagon and bulk log/ledger inserts — the + // per-wagon UPDATEs stay (patches differ per wagon). + const pinnedIds = (schedule.trainSet?.wagons ?? []) + .map((slot) => slot.physicalWagonId) + .filter((id): id is string => Boolean(id)); + const settleWagonById = new Map( + pinnedIds.length + ? ( + await manager.getRepository(Wagon).find({ where: { id: In(pinnedIds) } }) + ).map((w) => [w.id, w]) + : [], + ); + const arrivalLogRows: ScheduleWagonAdjustmentLog[] = []; + const arrivalMovementRows: WagonMovement[] = []; for (const slot of schedule.trainSet?.wagons ?? []) { if (!slot.physicalWagonId) continue; - const wagon = await manager - .getRepository(Wagon) - .findOne({ where: { id: slot.physicalWagonId } }); + const wagon = settleWagonById.get(slot.physicalWagonId); if (!wagon) continue; // A wagon that already alighted mid-route (unload released it, possibly // re-pinned elsewhere since) is no longer this schedule's to move. @@ -4839,7 +4885,7 @@ export class TrainSchedulingService { AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`, [wagon.id, ownerTrainId], ); - await manager.getRepository(ScheduleWagonAdjustmentLog).save( + arrivalLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, trainId: ownerTrainId, @@ -4863,7 +4909,7 @@ export class TrainSchedulingService { } // Ledger: the wagon rode this schedule to its settle yard. const slotAllocations = slot.allocations ?? []; - await manager.getRepository(WagonMovement).save( + arrivalMovementRows.push( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: slot.boardYardId ?? schedule.originStationId, @@ -4884,10 +4930,22 @@ export class TrainSchedulingService { // so a still-loose planned couple physically rode along. const arrivalCouplePlan = schedule.plannedWagonCouples ?? {}; const arrivalTrainId = schedule.trainSet?.trainId ?? null; - for (const [coupleWagonId, coupleYardId] of Object.entries(arrivalCouplePlan)) { - const wagon = await manager - .getRepository(Wagon) - .findOne({ where: { id: coupleWagonId } }); + const coupleEntries = Object.entries(arrivalCouplePlan); + const arrivalCoupleById = new Map( + coupleEntries.length + ? ( + await manager + .getRepository(Wagon) + .find({ where: { id: In(coupleEntries.map(([wagonId]) => wagonId)) } }) + ).map((w) => [w.id, w]) + : [], + ); + // Join sequence numbers continue after the settled consist; the max is + // read once and incremented locally — identical to re-querying after + // each join, without one consist scan per wagon. + let arrivalMaxSeq: number | null = null; + for (const [coupleWagonId, coupleYardId] of coupleEntries) { + const wagon = arrivalCoupleById.get(coupleWagonId); if (!wagon) continue; if (wagon.currentTrainScheduleId === scheduleId) { // Joined during the trip, slot-less: settle at the destination. @@ -4897,7 +4955,7 @@ export class TrainSchedulingService { status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, currentYardId: schedule.destinationStationId, }); - await manager.getRepository(WagonMovement).save( + arrivalMovementRows.push( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: coupleYardId, @@ -4914,18 +4972,21 @@ export class TrainSchedulingService { !wagon.currentTrainScheduleId && wagon.currentYardId === coupleYardId ) { - const consist = await manager.getRepository(Wagon).find({ - where: { trainId: arrivalTrainId }, - select: { id: true, sequenceNumber: true }, - }); - const maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + if (arrivalMaxSeq === null) { + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: arrivalTrainId }, + select: { id: true, sequenceNumber: true }, + }); + arrivalMaxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0); + } + arrivalMaxSeq += 1; await manager.getRepository(Wagon).update(wagon.id, { trainId: arrivalTrainId, - sequenceNumber: maxSeq + 1, + sequenceNumber: arrivalMaxSeq, status: WagonStatus.Assigned, currentYardId: schedule.destinationStationId, }); - await manager.getRepository(ScheduleWagonAdjustmentLog).save( + arrivalLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, trainId: arrivalTrainId, @@ -4937,7 +4998,7 @@ export class TrainSchedulingService { occurredAt: now, }), ); - await manager.getRepository(WagonMovement).save( + arrivalMovementRows.push( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: coupleYardId, @@ -4949,6 +5010,12 @@ export class TrainSchedulingService { ); } } + if (arrivalLogRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows); + } + if (arrivalMovementRows.length) { + await manager.getRepository(WagonMovement).save(arrivalMovementRows); + } // Ensure a destination checkpoint exists so the timeline shows ARRIVED. const stations = await this.buildScheduleStations(schedule); @@ -5720,6 +5787,39 @@ export class TrainSchedulingService { return rows[0]?.train_id ?? null; } + /** + * Polling heartbeat for the detail page: one row, no joins. Clients compare + * this snapshot between polls and refetch the (expensive) full detail only + * when it changed — `updatedAt` catches any schedule-row write, the phase + * fields drive countdowns directly. + */ + async getSchedulePhase(scheduleId: string) { + const rows: Array<{ + status: string; + bookingWindowStatus: string | null; + windowPhase: string | null; + windowOpensAt: Date | null; + windowClosesAt: Date | null; + docReviewEndsAt: Date | null; + paymentPhaseEndsAt: Date | null; + updatedAt: Date; + }> = await this.dataSource.query( + `SELECT status, + booking_window_status AS "bookingWindowStatus", + window_phase AS "windowPhase", + window_opens_at AS "windowOpensAt", + window_closes_at AS "windowClosesAt", + doc_review_ends_at AS "docReviewEndsAt", + payment_phase_ends_at AS "paymentPhaseEndsAt", + updated_at AS "updatedAt" + FROM freight.train_schedules + WHERE id = $1 AND deleted_at IS NULL`, + [scheduleId], + ); + if (!rows[0]) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + return rows[0]; + } + /** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */ private async plannedWagonYardsOf( scheduleId: string | undefined, @@ -5760,17 +5860,35 @@ export class TrainSchedulingService { return rows[0]?.planned_wagon_couples ?? {}; } + /** Wagon types are near-static reference data — 60s TTL like the batch service's dims cache. */ + private wagonTypesCache: { value: WagonType[]; expiresAt: number } | null = null; + + private async loadWagonTypesCached(): Promise { + if (this.wagonTypesCache && this.wagonTypesCache.expiresAt > Date.now()) { + return this.wagonTypesCache.value; + } + const value = await this.dataSource.getRepository(WagonType).find(); + this.wagonTypesCache = { value, expiresAt: Date.now() + 60_000 }; + return value; + } + private async countFleetAvailability( originYardId: string, targetScheduleId?: string, ): Promise> { - const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([ - this.dataSource.getRepository(Wagon).find(), - this.dataSource.getRepository(WagonType).find(), + const [wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([ + this.loadWagonTypesCached(), this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), this.plannedWagonYardsOf(targetScheduleId), ]); + // Only two wagon populations can ever count below: the built train's own + // consist, or (train-less schedules) loose wagons — `if (wagon.trainId) + // continue` used to drop everything else in JS after loading the whole + // national fleet. Same result, fleet-sized query avoided. + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: builtTrainId ? { trainId: builtTrainId } : { trainId: IsNull() }, + }); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); @@ -5945,9 +6063,16 @@ export class TrainSchedulingService { slots: TrainSetWagon[], reverseWagonOrder = false, ) { - const wagons = await manager.getRepository(Wagon).find(); - const wagonTypes = await manager.getRepository(WagonType).find(); const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager); + // pickPhysicalWagonForSlot can only ever pin the built train's own wagons, + // couple-planned loose wagons, or (loose-pool schedules) wagons with no + // train — its own filters reject everything else, so don't load the fleet. + const wagons = await manager.getRepository(Wagon).find({ + where: builtTrainId + ? [{ trainId: builtTrainId }, { trainId: IsNull() }] + : { trainId: IsNull() }, + }); + const wagonTypes = await this.loadWagonTypesCached(); const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule( scheduleId, manager, @@ -6034,11 +6159,17 @@ export class TrainSchedulingService { ): Promise { if (!wagonPlan.length) return []; - const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([ - this.dataSource.getRepository(Wagon).find(), + const [builtTrainId, pinnedToScheduleIds] = await Promise.all([ this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), ]); + // Same population argument as autoPinWagonsForSchedule: consist + loose + // wagons are the only candidates the pin filters can accept. + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: builtTrainId + ? [{ trainId: builtTrainId }, { trainId: IsNull() }] + : { trainId: IsNull() }, + }); const targetSchedule = targetScheduleId ? await this.trainSchedulesRepository.findById(targetScheduleId) : null; @@ -6906,7 +7037,9 @@ export class TrainSchedulingService { * (already carrying this schedule's cargo). */ async getScheduleWagonYards(scheduleId: string) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + // Slim graph: this read needs stops, the built train, and which slots + // carry allocations — not the full booking/container branches. + const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); const builtTrain = schedule.trainSet?.train; if (!builtTrain) { @@ -8227,7 +8360,7 @@ export class TrainSchedulingService { * through iam.users; rows survive wagon/train deletion (log tables carry * plain columns, no FKs). */ - async getScheduleHistory(scheduleId: string) { + async getScheduleHistory(scheduleId: string, query: { page?: number; pageSize?: number } = {}) { type HistoryRow = { id: string; kind: 'WAGON' | 'BOOKING'; @@ -8238,105 +8371,83 @@ export class TrainSchedulingService { note: string | null; occurredAt: Date; }; - const wagonRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT l.id, - l.action, - l.wagon_number AS "subject", - COALESCE(y.label, y.code) AS "yardLabel", - COALESCE(u.username, u.email) AS "actor", - l.occurred_at AS "occurredAt" - FROM freight.schedule_wagon_adjustment_logs l - LEFT JOIN freight.yards y ON y.id = l.yard_id - LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id - WHERE l.train_schedule_id = $1 - AND l.deleted_at IS NULL - ORDER BY l.occurred_at DESC - LIMIT 200`, + const { page, pageSize, skip, take } = normalizePagination(query); + // One UNION ALL over the four event sources, paginated in SQL — the old + // shape capped each source at 200 and merge-sorted up to 800 rows in + // memory per request. Same rows, same order, same field mapping. + const historyCte = ` + SELECT l.id::text AS "id", + 'WAGON' AS "kind", + l.action AS "action", + l.wagon_number AS "subject", + COALESCE(y.label, y.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + NULL::text AS "note", + l.occurred_at AS "occurredAt" + FROM freight.schedule_wagon_adjustment_logs l + LEFT JOIN freight.yards y ON y.id = l.yard_id + LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id + WHERE l.train_schedule_id = $1 + AND l.deleted_at IS NULL + UNION ALL + SELECT r.id::text, + 'BOOKING', + 'BOOKING_REMOVED', + r.booking_reference, + NULL, + COALESCE(u.username, u.email), + r.notes, + r.removed_at + FROM freight.train_composition_removal_logs r + LEFT JOIN iam.users u ON u.id = r.removed_by_user_id + WHERE r.schedule_id = $1 + AND r.deleted_at IS NULL + UNION ALL + SELECT b.id::text, + 'BOOKING', + 'BOOKING_LOADED', + b.reference, + COALESCE(oy.label, oy.code), + COALESCE(u.username, u.email), + NULL, + b.loaded_at + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id + WHERE b.loaded_at IS NOT NULL + AND b.deleted_at IS NULL + UNION ALL + SELECT b.id::text, + 'BOOKING', + 'BOOKING_UNLOADED', + b.reference, + COALESCE(dy.label, dy.code), + COALESCE(u.username, u.email), + NULL, + b.arrived_at + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id + WHERE b.arrived_at IS NOT NULL + AND b.deleted_at IS NULL`; + const [countRows, rows]: [Array<{ total: string }>, HistoryRow[]] = await Promise.all([ + this.dataSource.query( + `SELECT count(*) AS total FROM (${historyCte}) history`, [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'WAGON' as const, - note: null, - })); - const bookingRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT r.id, - r.booking_reference AS "subject", - r.notes AS "note", - COALESCE(u.username, u.email) AS "actor", - r.removed_at AS "occurredAt" - FROM freight.train_composition_removal_logs r - LEFT JOIN iam.users u ON u.id = r.removed_by_user_id - WHERE r.schedule_id = $1 - AND r.deleted_at IS NULL - ORDER BY r.removed_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_REMOVED', - yardLabel: null, - })); - // Per-booking journey events (load at boarding yard / unload at alighting - // yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a - // multi-stop train's disjoint legs (a→b loads then unloads at b while a→c - // rides through) each show as their own row. Append-only: these columns are - // only ever set once per booking, never cleared, so rows never disappear. - const journeyRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT b.id, - b.reference AS "subject", - COALESCE(oy.label, oy.code) AS "yardLabel", - COALESCE(u.username, u.email) AS "actor", - b.loaded_at AS "occurredAt" - FROM freight.bookings b - JOIN freight.train_schedule_bookings tsb - ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id - LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id - WHERE b.loaded_at IS NOT NULL - AND b.deleted_at IS NULL - ORDER BY b.loaded_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_LOADED', - note: null, - })); - const unloadRows: HistoryRow[] = ( - await this.dataSource.query( - `SELECT b.id, - b.reference AS "subject", - COALESCE(dy.label, dy.code) AS "yardLabel", - COALESCE(u.username, u.email) AS "actor", - b.arrived_at AS "occurredAt" - FROM freight.bookings b - JOIN freight.train_schedule_bookings tsb - ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id - LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id - WHERE b.arrived_at IS NOT NULL - AND b.deleted_at IS NULL - ORDER BY b.arrived_at DESC - LIMIT 200`, - [scheduleId], - ) - ).map((r: Omit) => ({ - ...r, - kind: 'BOOKING' as const, - action: 'BOOKING_UNLOADED', - note: null, - })); - return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort( - (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), - ); + ), + this.dataSource.query( + `SELECT * FROM (${historyCte}) history + ORDER BY "occurredAt" DESC + LIMIT $2 OFFSET $3`, + [scheduleId, take, skip], + ), + ]); + const total = Number(countRows[0]?.total ?? 0); + return { items: rows, meta: buildPaginationMeta(total, page, pageSize) }; } /** @@ -9752,12 +9863,24 @@ export class TrainSchedulingService { * yardId → display label for error messages that name corridor legs. One * query; unknown ids fall back to the raw id so a message never goes blank. */ + private yardLabelsCache: { value: Map; expiresAt: number } | null = null; + private async yardLabelMap(yardIds: string[]): Promise> { if (!yardIds.length) return new Map(); - const yards = await this.dataSource - .getRepository(Yard) - .find({ where: { id: In(yardIds) } }); - return new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + // Yards are near-static — cache the whole label map for 60s instead of + // one IN(...) query per detail/board render. A missing id degrades exactly + // as before: the consumer falls back to the raw id. + if (!this.yardLabelsCache || this.yardLabelsCache.expiresAt <= Date.now()) { + const yards = await this.dataSource.getRepository(Yard).find(); + this.yardLabelsCache = { + value: new Map(yards.map((y) => [y.id, y.label || y.code || y.id])), + expiresAt: Date.now() + 60_000, + }; + } + const all = this.yardLabelsCache.value; + return new Map( + yardIds.filter((id) => all.has(id)).map((id) => [id, all.get(id) as string]), + ); } /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx index 729c7a5d4..f99eeaef5 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleHistoryPanel.tsx @@ -1,6 +1,7 @@ import { Badge, Group, + Pagination, Paper, Stack, Text, @@ -8,6 +9,7 @@ import { Timeline, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; import { ArrowLeftRight, History, @@ -41,13 +43,18 @@ const ACTION_META: Record< * bookings removed from the composition — newest first. */ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) { + const [page, setPage] = useState(1); const historyQuery = useQuery( api.trainScheduling.scheduleHistory.queryOptions({ - input: { scheduleId }, + input: { scheduleId, page, pageSize: 20 }, enabled: Boolean(scheduleId), + // Keep the previous page on screen while the next one loads. + placeholderData: (prev) => prev, }), ); - const entries = historyQuery.data ?? []; + const entries = historyQuery.data?.items ?? []; + const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1); + const total = historyQuery.data?.meta.total ?? 0; return ( @@ -131,6 +138,15 @@ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: strin })} )} + + {totalPages > 1 ? ( + + + {total} change(s) + + + + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx index 1aebe90c3..7b3ea1780 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx @@ -10,13 +10,15 @@ import { Group, Loader, Modal, + Pagination, Paper, Stack, + Tabs, Text, Textarea, ThemeIcon, } from "@mantine/core"; -import { AlertCircle, Check, Clock, Link2, X } from "lucide-react"; +import { AlertCircle, Check, Clock, Link2, User, X } from "lucide-react"; import toast from "react-hot-toast"; import { PageContainer, PageHeader } from "@/components/page"; @@ -28,6 +30,39 @@ import { formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; const QUEUE_KEY = ["consolidation-approvals", "queue"]; +const PAGE_SIZE = 10; + +type Status = ConsolidationApprovalRow["status"]; + +const TABS: { value: Status; label: string }[] = [ + { value: "PENDING", label: "Awaiting approval" }, + { value: "APPROVED", label: "Approved" }, + { value: "REJECTED", label: "Rejected" }, +]; + +const STATUS_COLOR: Record = { + PENDING: "yellow", + APPROVED: "green", + REJECTED: "red", +}; + +const STATUS_LABEL: Record = { + PENDING: "Awaiting approval", + APPROVED: "Approved", + REJECTED: "Rejected", +}; + +const STATUS_VERB: Record = { + PENDING: "", + APPROVED: "Approved by", + REJECTED: "Rejected by", +}; + +const EMPTY_TEXT: Record = { + PENDING: "Nothing waiting for approval.", + APPROVED: "No shared wagon has been approved yet.", + REJECTED: "No shared wagon has been rejected.", +}; /** * Review queue for shared-wagon pairings. @@ -37,6 +72,11 @@ const QUEUE_KEY = ["consolidation-approvals", "queue"]; * under two separate invoices, so a person signs off on the pairing first. * Approving releases BOTH bookings to Operations; rejecting sends BOTH back to * GL with the reason. + * + * Decided pairings stay on the page rather than vanishing: the decided tabs are + * the record of who signed off on which wagon and why. A rejection is not final + * either — a rejected pairing can still be approved from here once whatever + * blocked it is settled. */ export default function ConsolidationApprovalsPage() { const qc = useQueryClient(); @@ -45,16 +85,31 @@ export default function ConsolidationApprovalsPage() { kind: "approve" | "reject"; } | null>(null); const [note, setNote] = useState(""); + const [tab, setTab] = useState("PENDING"); + const [page, setPage] = useState(1); - const { - data: rows, - isLoading, - isError, - } = useQuery({ - queryKey: QUEUE_KEY, - queryFn: () => bookingsService.consolidationApprovalQueue(), + const { data, isLoading, isError, isFetching } = useQuery({ + queryKey: [...QUEUE_KEY, tab, page], + queryFn: () => + bookingsService.consolidationApprovalQueue({ + status: tab, + page, + pageSize: PAGE_SIZE, + }), + // Keeping the last page on screen while the next one loads stops the list + // from collapsing to a spinner on every page or tab click. + placeholderData: (previous) => previous, }); + const shown = data?.items ?? []; + const pageCount = Math.max(1, data?.meta.totalPages ?? 1); + const countOf = (status: Status) => data?.counts?.[status] ?? 0; + + const goToTab = (next: Status) => { + setTab(next); + setPage(1); + }; + const close = () => { setDecision(null); setNote(""); @@ -64,7 +119,10 @@ export default function ConsolidationApprovalsPage() { mutationFn: () => { if (!decision) throw new Error("No pairing selected"); return decision.kind === "approve" - ? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined) + ? bookingsService.approveConsolidation( + decision.row.id, + note.trim() || undefined, + ) : bookingsService.rejectConsolidation(decision.row.id, note.trim()); }, onSuccess: () => { @@ -73,6 +131,7 @@ export default function ConsolidationApprovalsPage() { ? "Shared wagon approved — both bookings sent to Operations" : "Shared wagon rejected — both bookings returned to GL", ); + goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED"); void qc.invalidateQueries({ queryKey: QUEUE_KEY }); close(); }, @@ -99,90 +158,190 @@ export default function ConsolidationApprovalsPage() { }> Could not load the approval queue. - ) : !rows?.length ? ( - }> - Nothing waiting for approval. - ) : ( - - {rows.map((row) => ( - - - - - - - - - Shared wagon - - - Awaiting approval - - - - - - - - - - - - Requested {formatDateTime(row.requestedAt)} - {row.scheduledDate - ? ` · ships ${formatDateTime(row.scheduledDate)}` - : ""} - - - - - - - + {countOf(value)} + + } + > + {label} + + ))} + + + {!shown.length ? ( + }> + {EMPTY_TEXT[tab]} + + ) : ( + + {shown.map((row) => ( + + + + + + + + + Shared wagon + + + {STATUS_LABEL[row.status]} + + + + + + + + + + + + Requested {formatDateTime(row.requestedAt)} + {row.requestedByName + ? ` by ${row.requestedByName}` + : ""} + {row.scheduledDate + ? ` · ships ${formatDateTime(row.scheduledDate)}` + : ""} + + + + {row.status !== "PENDING" && ( + + + + + {STATUS_VERB[row.status]}{" "} + {row.decidedByName ?? "an unknown user"} + {row.decidedAt + ? ` on ${formatDateTime(row.decidedAt)}` + : ""} + + {row.decisionNote && ( + + “{row.decisionNote}” + + )} + + + )} + + + {row.status !== "APPROVED" && ( + + + {row.status === "PENDING" && ( + + )} + + )} + + + ))} + + {pageCount > 1 && ( + + + Showing {(page - 1) * PAGE_SIZE + 1}– + {Math.min(page * PAGE_SIZE, data?.total ?? 0)} of{" "} + {data?.total ?? 0} + + - - - ))} - + )} + + )} + )} - {decision?.kind === "approve" - ? "Approve this shared wagon?" - : "Reject this shared wagon?"} + {decision?.kind !== "approve" + ? "Reject this shared wagon?" + : decision.row.status === "REJECTED" + ? "Approve this rejected shared wagon?" + : "Approve this shared wagon?"} } > - {decision?.kind === "approve" - ? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately." - : "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."} + {decision?.kind !== "approve" + ? "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations." + : decision.row.status === "REJECTED" + ? "This pairing was rejected before. Approving it now overrides that decision — both bookings leave the gate together and continue to Operations." + : "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."}