From 40904049cf781016e1b6d9a671ae8aff7e0c72fd Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 18 Aug 2026 13:17:55 +0000 Subject: [PATCH] feat: implement consolidation approval process for shared-wagon bookings - Add migration for consolidation approvals table and status enum - Create ConsolidationApprovalService to handle approval logic - Implement repository for managing consolidation approvals - Add entity for consolidation approval with necessary fields - Develop frontend components for displaying and managing consolidation approvals - Create tests for consolidation approval service to ensure correct behavior --- .../3570000000000-ConsolidationApprovals.ts | 89 ++++++ .../booking-lifecycle-notifier.service.ts | 30 ++ .../modules/bookings/bookings.controller.ts | 65 ++++ .../src/modules/bookings/bookings.module.ts | 8 + .../consolidation-approval.service.spec.ts | 202 +++++++++++++ .../consolidation-approval.service.ts | 255 ++++++++++++++++ .../consolidation-approvals.repository.ts | 120 ++++++++ .../bookings/dto/request-changes.dto.ts | 23 ++ .../bookings/entities/booking.entity.ts | 4 + .../entities/consolidation-approval.entity.ts | 98 ++++++ .../contract-booking.completion.spec.ts | 2 + .../contract-booking.consolidation.spec.ts | 1 + .../contract-booking.initiate-gate.spec.ts | 1 + ...tract-booking.manual-consolidation.spec.ts | 3 + .../contract-booking.resubmit-cargo.spec.ts | 1 + .../contracts/contract-booking.service.ts | 15 + .../contracts/contract-clearance.service.ts | 53 ++-- .../modules/contracts/contracts.controller.ts | 3 + .../modules/contracts/contracts.repository.ts | 25 ++ .../src/seed/freight-permissions.registry.ts | 25 ++ apps/edr-freight-web/backoffice/src/App.tsx | 13 + .../detail/ConsolidationApprovalCard.tsx | 82 +++++ .../trainBuilder/AvailableWagonsPanel.tsx | 199 +++++++----- .../trainBuilder/ConsistWagonList.tsx | 36 ++- .../TrainCompositionDiagram.tsx | 14 +- .../backoffice/src/constants/URLS.ts | 9 + .../bookings/booking-status.config.ts | 8 + .../backoffice/src/lib/permissions.ts | 1 + .../backoffice/src/lib/queryClient.test.ts | 58 ++++ .../backoffice/src/lib/queryClient.ts | 28 +- .../bookings/BookingRequestDetailPage.tsx | 19 ++ .../bookings/ConsolidationApprovalsPage.tsx | 284 ++++++++++++++++++ .../trainBuilder/TrainBuilderDetailPage.tsx | 146 ++++++--- .../backoffice/src/services/api.ts | 43 ++- .../src/services/bookings.service.ts | 63 ++++ .../backoffice/src/utils/endpoint.ts | 56 +++- 36 files changed, 1898 insertions(+), 184 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx diff --git a/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts b/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts new file mode 100644 index 000000000..879c9b472 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts @@ -0,0 +1,89 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Approval gate for consolidated (shared-wagon) bookings. + * + * A booking that fills its own wagons goes straight from GL completion to the + * operations queue. A CONSOLIDATED booking does not: it shares one physical + * wagon with another customer's booking, which means two customers' cargo, two + * invoices and two liabilities riding the same wagon. That pairing is a + * commercial decision, so it is reviewed by a person before Operations sees it. + * + * The pair is approved as a UNIT — one row covers both halves (booking_id + + * partner_booking_id) so an approver can never approve one side of a shared + * wagon and leave the other pending. Rows are never deleted; decided rows are + * the audit trail of who approved which pairing and when. + * + * One PENDING row per booking at a time (partial unique index on each side of + * the pair): a second request while one is undecided is a coordination failure, + * not a workflow. + */ +export class ConsolidationApprovals3570000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.consolidation_approvals_status_enum + AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.consolidation_approvals ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + booking_id uuid NOT NULL REFERENCES freight.bookings (id), + partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id), + status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING', + -- Who put the pairing up for review (the GL user who completed it) and + -- who decided it. Both are recorded: the point of the gate is that they + -- are different people. + requested_by uuid, + requested_at timestamptz NOT NULL DEFAULT now(), + decided_by uuid, + decided_at timestamptz, + decision_note varchar(500), + -- Snapshot of what was approved, so the audit trail still reads + -- correctly after the bookings themselves move on. + scheduled_date timestamptz, + booking_reference varchar(50), + partner_booking_reference varchar(50), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_booking_status + ON freight.consolidation_approvals (booking_id, status) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status + ON freight.consolidation_approvals (status) + `); + + // The workflow invariant, enforced where it cannot race: at most one + // undecided request per booking — on EITHER side of the pair, so the same + // wagon can never collect two pending requests from its two halves. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending + ON freight.consolidation_approvals (booking_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner + ON freight.consolidation_approvals (partner_booking_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.consolidation_approvals`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 01f0fd053..7755a870b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -463,6 +463,36 @@ export class BookingLifecycleNotifierService { ); } + /** + * A shared-wagon pairing is waiting for a human decision. Two customers' cargo + * on one wagon is a commercial call, so this never auto-advances. + */ + consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void { + this.inAppStaff( + b, + 'Shared wagon needs approval', + `Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`, + ); + } + + /** The pairing was approved; both halves move on to Operations together. */ + consolidationApprovedToStaff(b: Booking, partnerReference: string): void { + this.inAppStaff( + b, + 'Shared wagon approved', + `The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`, + ); + } + + /** The pairing was rejected; both halves go back to GL for changes. */ + consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void { + this.inAppStaff( + b, + 'Shared wagon rejected', + `The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`, + ); + } + /** Customer uploaded clearance documents — review is next. */ clearanceDocsUploadedToStaff(b: Booking): void { this.inAppStaff( 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 c2d6465c2..67a0f930d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -50,6 +50,7 @@ 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'; @@ -58,8 +59,10 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, + ApproveConsolidationDto, CancelBookingDto, PairedDecisionDto, + RejectConsolidationDto, RejectBookingDto, RequestChangesDto, ReviewDocumentDto, @@ -166,6 +169,7 @@ export class BookingsController { private readonly lastMileService: LastMileService, private readonly userTradeAccessService: UserTradeAccessService, private readonly wagonCancellationService: BookingWagonCancellationService, + private readonly consolidationApprovalService: ConsolidationApprovalService, ) {} @Post() @@ -1542,6 +1546,67 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // ── Shared-wagon (consolidation) approval gate ──────────────────────────── + // A consolidated pair is held here, not in the operations queue: two + // customers' cargo on one wagon is a commercial call, so a person signs off + // on the pairing before Operations sees either half. + + @Get("consolidation-approvals/queue") + @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiOperation({ + summary: + "Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.", + }) + consolidationApprovalQueue() { + return this.consolidationApprovalService.queue(); + } + + @Get(":id/consolidation-approvals") + @BookingStaff(FREIGHT_PERMS.bookings.view) + @ApiOperation({ + summary: + "Approval history for this booking's shared wagon — who decided what, when, and why.", + }) + consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) { + return this.consolidationApprovalService.historyForBooking(id); + } + + @Post("consolidation-approvals/:approvalId/approve") + @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiOperation({ + summary: + "Approve a shared wagon: both bookings leave the gate and continue to Operations together.", + }) + approveConsolidation( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: ApproveConsolidationDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.consolidationApprovalService.approve( + approvalId, + resolveAuthUserId(user) ?? "", + dto.note, + ); + } + + @Post("consolidation-approvals/:approvalId/reject") + @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) + @ApiOperation({ + summary: + "Reject a shared wagon: both bookings go back to GL for changes with the reason.", + }) + rejectConsolidation( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: RejectConsolidationDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.consolidationApprovalService.reject( + approvalId, + resolveAuthUserId(user) ?? "", + dto.reason, + ); + } + @Post(":id/paired-decision") @BookingStaff(FREIGHT_PERMS.bookings.cancel) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 447292117..b3ea4a546 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -30,6 +30,9 @@ import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { ConsolidationApprovalService } from './consolidation-approval.service'; +import { ConsolidationApprovalsRepository } from './consolidation-approvals.repository'; +import { ConsolidationApproval } from './entities/consolidation-approval.entity'; import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -72,6 +75,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingWagonCancellation, CustomerTruckAssignment, CustomerTruckContainer, + ConsolidationApproval, ]), BillingModule, DocumentsModule, @@ -98,6 +102,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsService, BookingsRepository, ConsolidationService, + ConsolidationApprovalService, + ConsolidationApprovalsRepository, ContainerValidationService, BookingReferenceDataService, BookingPricingService, @@ -126,6 +132,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingLifecycleNotifierService, BookingTransitionService, ConsolidationService, + ConsolidationApprovalService, + ConsolidationApprovalsRepository, CustomerTruckService, ContainerReceiptService, BookingWagonCancellationService, 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 new file mode 100644 index 000000000..d418fbd3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts @@ -0,0 +1,202 @@ +import { + ConsolidationApprovalService, + CONSOLIDATION_APPROVAL_PENDING, +} 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 + * commercial call, so the pair is held for a human decision instead of going + * straight to Operations. + * + * The invariants that matter: both halves are held and released TOGETHER (a + * decision on one side of a shared wagon is meaningless without the other), the + * requester cannot approve their own pairing, and a decided pairing cannot be + * decided twice. + */ +describe('ConsolidationApprovalService', () => { + const PENDING = { + id: 'ap-1', + bookingId: 'b-1', + partnerBookingId: 'b-2', + status: ConsolidationApprovalStatus.Pending, + requestedBy: 'gl-user', + }; + + function makeService(overrides: { + approvals?: Partial>; + bookingsRepository?: Partial>; + } = {}) { + const approvals = { + findPendingForBooking: jest.fn().mockResolvedValue(null), + findById: jest.fn().mockResolvedValue(PENDING), + create: jest.fn().mockResolvedValue({ id: 'ap-1' }), + decide: jest.fn().mockResolvedValue(true), + findQueue: jest.fn().mockResolvedValue([]), + findAllForBooking: jest.fn().mockResolvedValue([]), + ...overrides.approvals, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue(undefined), + createReviewNote: jest.fn().mockResolvedValue(undefined), + ...overrides.bookingsRepository, + }; + const bookingsService = { + findById: jest.fn(async (id: string) => + ({ id, reference: `BK-${id}` }) as Booking, + ), + }; + const notifier = { + consolidationApprovalRequestedToStaff: jest.fn(), + consolidationApprovedToStaff: jest.fn(), + consolidationRejectedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + }; + const dataSource = { + transaction: jest.fn(async (cb: () => Promise) => cb()), + }; + + const service = new ConsolidationApprovalService( + approvals as never, + bookingsRepository as never, + bookingsService as never, + notifier as never, + dataSource as never, + ); + return { service, approvals, bookingsRepository, notifier }; + } + + 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'); + + expect(approvals.create).toHaveBeenCalledWith( + expect.objectContaining({ + 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', { + status: CONSOLIDATION_APPROVAL_PENDING, + }); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { + status: CONSOLIDATION_APPROVAL_PENDING, + }); + expect( + notifier.consolidationApprovalRequestedToStaff, + ).toHaveBeenCalledTimes(1); + }); + + 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'); + + expect(result).toBe(PENDING); + expect(approvals.create).not.toHaveBeenCalled(); + }); + + 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'); + + expect(approvals.decide).toHaveBeenCalledWith( + 'ap-1', + ConsolidationApprovalStatus.Approved, + 'approver-1', + 'looks fine', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + 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 () => { + const { service, approvals, bookingsRepository } = makeService(); + + await service.reject('ap-1', 'approver-1', 'partner cargo is wrong'); + + expect(approvals.decide).toHaveBeenCalledWith( + 'ap-1', + ConsolidationApprovalStatus.Rejected, + 'approver-1', + 'partner cargo is wrong', + ); + expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( + 'b-1', + 'partner cargo is wrong', + 'CHANGES_REQUESTED', + ); + expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( + 'b-2', + 'partner cargo is wrong', + 'CHANGES_REQUESTED', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + status: 'OPERATION_CHANGES_REQUESTED', + }); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', { + status: 'OPERATION_CHANGES_REQUESTED', + }); + }); + + it('refuses to let the requester approve their own pairing', async () => { + const { service, bookingsRepository } = makeService(); + + await expect( + service.approve('ap-1', 'gl-user'), + ).rejects.toThrow(/must be approved by someone else/i); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('requires a reason to reject', async () => { + const { service, approvals } = makeService(); + + 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 () => { + const { service, bookingsRepository } = makeService({ + approvals: { + findById: jest.fn().mockResolvedValue({ + ...PENDING, + status: ConsolidationApprovalStatus.Approved, + }), + }, + }); + + 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 () => { + // 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( + /already decided by someone else/i, + ); + }); +}); 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 new file mode 100644 index 000000000..ed8bdfe7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts @@ -0,0 +1,255 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + Logger, + NotFoundException, + forwardRef, +} from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { Booking } from "./entities/booking.entity"; +import { + ConsolidationApproval, + ConsolidationApprovalStatus, +} from "./entities/consolidation-approval.entity"; +import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository"; +import { BookingsRepository } from "./bookings.repository"; +import { BookingsService } from "./bookings.service"; +import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service"; + +/** Where a rejected pair goes back to, so GL can fix and resubmit. */ +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"; + +/** + * The shared-wagon approval gate. + * + * A booking that fills its own wagons goes straight from GL completion to the + * operations queue. A consolidated one does not: two customers' cargo rides one + * physical wagon under two separate invoices, so a person reviews the pairing + * before Operations sees either half. + * + * Both halves are held and released TOGETHER — the wagon is shared, so a + * decision on one is meaningless without the other. Every request is kept, + * decided or not: the table is the audit trail of who approved which pairing, + * when, and why. + */ +@Injectable() +export class ConsolidationApprovalService { + private readonly logger = new Logger(ConsolidationApprovalService.name); + + constructor( + private readonly approvals: ConsolidationApprovalsRepository, + private readonly bookingsRepository: BookingsRepository, + @Inject(forwardRef(() => BookingsService)) + private readonly bookingsService: BookingsService, + private readonly notifier: BookingLifecycleNotifierService, + private readonly dataSource: DataSource, + ) {} + + /** + * Park a newly consolidated pair for review instead of letting it continue to + * Operations. Called from the completion path once the two halves are linked. + * + * Idempotent: a pair that already has an undecided request is left alone, so a + * retried completion cannot open a second review of the same wagon. + */ + async requestApproval( + bookingId: string, + partnerBookingId: string, + requestedBy: string | null, + ): Promise { + const existing = await this.approvals.findPendingForBooking(bookingId); + if (existing) return existing; + + // Sequential reads: one connection per transaction context. + const booking = await this.bookingsService.findById(bookingId); + const partner = await this.bookingsService.findById(partnerBookingId); + if (!booking || !partner) { + throw new NotFoundException("Both bookings of the pair must exist."); + } + + const approval = await this.approvals.create({ + bookingId, + partnerBookingId, + requestedBy, + scheduledDate: booking.scheduledDate ?? null, + bookingReference: booking.reference ?? null, + partnerBookingReference: partner.reference ?? null, + }); + + // Hold BOTH halves: the wagon is shared, so neither may advance alone. + await this.bookingsRepository.update(bookingId, { + status: CONSOLIDATION_APPROVAL_PENDING, + } as never); + await this.bookingsRepository.update(partnerBookingId, { + status: CONSOLIDATION_APPROVAL_PENDING, + } as never); + + this.notifier.consolidationApprovalRequestedToStaff( + booking, + partner.reference ?? partnerBookingId, + ); + this.logger.log( + `Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`, + ); + return approval; + } + + /** + * Approve the pairing: both halves leave the gate and continue to Operations, + * which is exactly where a non-consolidated booking would already be. + * + * All-or-nothing — the two status writes and the decision record share one + * transaction, so the audit trail can never claim an approval that did not + * take effect. + */ + async approve( + approvalId: string, + decidedBy: string, + note?: string, + ): Promise<{ booking: Booking; partner: Booking }> { + const approval = await this.loadPending(approvalId); + this.assertDifferentPerson(approval, decidedBy); + + await this.dataSource.transaction(async () => { + const claimed = await this.approvals.decide( + approval.id, + ConsolidationApprovalStatus.Approved, + decidedBy, + note, + ); + // Lost the race to another approver deciding the same pairing. + if (!claimed) { + throw new ConflictException( + "This consolidation was already decided by someone else.", + ); + } + await this.bookingsRepository.update(approval.bookingId, { + status: "OPERATION_REQUEST_PENDING", + } as never); + await this.bookingsRepository.update(approval.partnerBookingId, { + status: "OPERATION_REQUEST_PENDING", + } as never); + }); + + const booking = await this.bookingsService.findById(approval.bookingId); + const partner = await this.bookingsService.findById( + approval.partnerBookingId, + ); + this.notifier.consolidationApprovedToStaff( + booking, + partner.reference ?? approval.partnerBookingId, + ); + // Operations only now learns about the pair — the gate is what kept it out. + this.notifier.operationRequestedToStaff(booking); + this.notifier.operationRequestedToStaff(partner); + return { booking, partner }; + } + + /** + * Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED + * with the reason, so the cargo or the partner can be changed and resubmitted. + */ + async reject( + approvalId: string, + decidedBy: string, + reason: string, + ): 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); + this.assertDifferentPerson(approval, decidedBy); + + await this.dataSource.transaction(async () => { + const claimed = await this.approvals.decide( + approval.id, + ConsolidationApprovalStatus.Rejected, + decidedBy, + reason.trim(), + ); + if (!claimed) { + throw new ConflictException( + "This consolidation was already decided by someone else.", + ); + } + await this.bookingsRepository.createReviewNote( + approval.bookingId, + reason.trim(), + "CHANGES_REQUESTED", + ); + await this.bookingsRepository.createReviewNote( + approval.partnerBookingId, + reason.trim(), + "CHANGES_REQUESTED", + ); + await this.bookingsRepository.update(approval.bookingId, { + status: REJECTED_STATUS, + } as never); + await this.bookingsRepository.update(approval.partnerBookingId, { + status: REJECTED_STATUS, + } as never); + }); + + const booking = await this.bookingsService.findById(approval.bookingId); + const partner = await this.bookingsService.findById( + approval.partnerBookingId, + ); + this.notifier.consolidationRejectedToStaff( + booking, + partner.reference ?? approval.partnerBookingId, + reason.trim(), + ); + return { booking, partner }; + } + + /** Pending pairings awaiting a decision, oldest first. */ + queue(): Promise { + return this.approvals.findQueue(); + } + + /** Full decision history for one booking — who decided what, and when. */ + historyForBooking(bookingId: string): Promise { + return this.approvals.findAllForBooking(bookingId); + } + + /** The undecided request covering this booking, if any. */ + pendingForBooking(bookingId: string): Promise { + return this.approvals.findPendingForBooking(bookingId); + } + + private async loadPending(approvalId: string): Promise { + const approval = await this.approvals.findById(approvalId); + if (!approval) { + throw new NotFoundException(`Approval ${approvalId} not found`); + } + if (approval.status !== ConsolidationApprovalStatus.Pending) { + throw new ConflictException( + `This consolidation was already ${approval.status.toLowerCase()}.`, + ); + } + return approval; + } + + /** + * Maker–checker: the point of the gate is a second pair of eyes, so the GL + * user who created the pairing cannot also approve it. + */ + private assertDifferentPerson( + approval: ConsolidationApproval, + decidedBy: string, + ): void { + if (approval.requestedBy && approval.requestedBy === decidedBy) { + throw new BadRequestException( + "You created this consolidation — it must be approved by someone else.", + ); + } + } +} 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 new file mode 100644 index 000000000..b398e7e8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approvals.repository.ts @@ -0,0 +1,120 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, In, Repository } from "typeorm"; + +import { + ConsolidationApproval, + ConsolidationApprovalStatus, +} from "./entities/consolidation-approval.entity"; + +/** + * Persistence for the shared-wagon approval gate. Rows are never deleted — + * decided rows are the audit trail of who approved which pairing and when. + */ +@Injectable() +export class ConsolidationApprovalsRepository { + private readonly repository: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repository = this.dataSource.getRepository(ConsolidationApproval); + } + + /** + * The undecided request covering `bookingId`, from EITHER side of the pair — + * one row governs both halves, and the caller may hold either one. + */ + findPendingForBooking( + bookingId: string, + ): Promise { + return this.repository.findOne({ + where: [ + { bookingId, status: ConsolidationApprovalStatus.Pending }, + { + partnerBookingId: bookingId, + status: ConsolidationApprovalStatus.Pending, + }, + ], + }); + } + + /** Every request touching this booking, newest first (the audit trail). */ + findAllForBooking(bookingId: string): Promise { + return this.repository.find({ + where: [{ bookingId }, { partnerBookingId: bookingId }], + order: { createdAt: "DESC" }, + }); + } + + findById(id: string): Promise { + 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" }, + }); + } + + create(input: { + bookingId: string; + partnerBookingId: string; + requestedBy?: string | null; + scheduledDate?: Date | null; + bookingReference?: string | null; + partnerBookingReference?: string | null; + }): Promise { + return this.repository.save( + this.repository.create({ + ...input, + status: ConsolidationApprovalStatus.Pending, + requestedAt: new Date(), + }), + ); + } + + /** + * Record the decision. Written only against a row still PENDING, so two + * approvers racing on the same pairing cannot both succeed — the second + * update matches nothing and the caller sees `false`. + */ + async decide( + id: string, + status: + | ConsolidationApprovalStatus.Approved + | ConsolidationApprovalStatus.Rejected, + decidedBy: string | null, + decisionNote?: string | null, + ): Promise { + const result = await this.repository.update( + { id, status: ConsolidationApprovalStatus.Pending }, + { + status, + decidedBy, + decidedAt: new Date(), + decisionNote: decisionNote ?? null, + }, + ); + return (result.affected ?? 0) > 0; + } + + /** Undecided requests covering any of these bookings (list badging). */ + findPendingForBookings( + bookingIds: string[], + ): Promise { + if (bookingIds.length === 0) return Promise.resolve([]); + return this.repository.find({ + where: [ + { bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending }, + { + partnerBookingId: In(bookingIds), + status: ConsolidationApprovalStatus.Pending, + }, + ], + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 9c4e5c944..9631def73 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -158,3 +158,26 @@ export class PairedDecisionDto { @Min(1) validityDays?: number; } + +/** Approve a shared-wagon pairing. The note is optional context for the audit. */ +export class ApproveConsolidationDto { + @ApiPropertyOptional({ + description: "Optional note recorded with the approval.", + maxLength: 500, + }) + @IsOptional() + @IsString() + note?: string; +} + +/** Reject a shared-wagon pairing. A reason is mandatory — GL has to act on it. */ +export class RejectConsolidationDto { + @ApiProperty({ + description: + "Why the pairing is rejected. Sent back to GL on both bookings.", + maxLength: 500, + }) + @IsString() + @MinLength(1) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index cdcfbc3eb..c85e15cfb 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -58,6 +58,10 @@ export const BOOKING_STATUSES = [ // the booking enters the batch holding pool. 'OPERATION_REQUEST_PENDING', 'OPERATION_CHANGES_REQUESTED', + // Shared-wagon review gate: a consolidated pair waits for a human decision + // before either half reaches Operations. Two customers' cargo on one wagon is + // a commercial call, so it is never auto-advanced. + 'CONSOLIDATION_APPROVAL_PENDING', 'OPERATION_PRICE_PENDING_CONFIRM', ] as const; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts new file mode 100644 index 000000000..91c3dcdee --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/consolidation-approval.entity.ts @@ -0,0 +1,98 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Booking } from "./booking.entity"; + +export enum ConsolidationApprovalStatus { + Pending = "PENDING", + Approved = "APPROVED", + Rejected = "REJECTED", +} + +/** + * Approval gate for a consolidated (shared-wagon) booking pair. + * + * A booking that fills its own wagons goes straight from GL completion to the + * operations queue. A consolidated one does not: two customers' cargo rides one + * physical wagon, under two separate invoices and two separate liabilities. That + * pairing is a commercial decision, so a person reviews it before Operations + * sees either half. + * + * The pair is approved as a UNIT — one row covers both halves — so nobody can + * approve one side of a shared wagon and leave the other pending. Rows are never + * deleted: decided rows are the audit trail of who approved which pairing, when, + * and why. + */ +@Entity({ schema: "freight", name: "consolidation_approvals" }) +@Index(["bookingId", "status"]) +@Index(["status"]) +export class ConsolidationApproval extends BaseEntity { + @Column({ name: "booking_id", type: "uuid" }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "booking_id" }) + booking?: Booking; + + /** The other half of the shared wagon. */ + @Column({ name: "partner_booking_id", type: "uuid" }) + partnerBookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "partner_booking_id" }) + partnerBooking?: Booking; + + @Column({ + name: "status", + type: "enum", + enum: ConsolidationApprovalStatus, + default: ConsolidationApprovalStatus.Pending, + }) + status!: ConsolidationApprovalStatus; + + /** IAM user id of the GL staff whose completion created the pairing. */ + @Column({ name: "requested_by", type: "uuid", nullable: true }) + requestedBy?: string | null; + + @Column({ name: "requested_at", type: "timestamptz", default: () => "now()" }) + requestedAt!: Date; + + /** IAM user id of the approver; null while pending. */ + @Column({ name: "decided_by", type: "uuid", nullable: true }) + decidedBy?: string | null; + + @Column({ name: "decided_at", type: "timestamptz", nullable: true }) + decidedAt?: Date | null; + + /** Why it was approved or rejected. Required on reject, optional on approve. */ + @Column({ + name: "decision_note", + type: "varchar", + length: 500, + nullable: true, + }) + decisionNote?: string | null; + + // ── Snapshot ────────────────────────────────────────────────────────────── + // Copied at request time so the audit trail still reads correctly after the + // bookings themselves move on (rebooked to another day, cancelled, renamed). + + @Column({ name: "scheduled_date", type: "timestamptz", nullable: true }) + scheduledDate?: Date | null; + + @Column({ + name: "booking_reference", + type: "varchar", + length: 50, + nullable: true, + }) + bookingReference?: string | null; + + @Column({ + name: "partner_booking_reference", + type: "varchar", + length: 50, + nullable: true, + }) + partnerBookingReference?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index c222ffc16..bdb8d74cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -30,6 +30,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService + {} as never, // consolidationApprovalService ); return { service, contractsRepository }; } @@ -156,6 +157,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, {} as never, {} as never, + {} as never, // consolidationApprovalService ); return { service, contractsRepository }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index 84465145d..fb68b3f5c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService + {} as never, // consolidationApprovalService ); return { service, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts index 6b5f54c91..6155eb583 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts @@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => { {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService + {} as never, // consolidationApprovalService ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index 841baab31..49e77627f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -44,6 +44,9 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService + // The pairing is parked for approval rather than going straight to + // Operations; the gate itself is covered by its own spec. + { requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never, ); return { service, bookingsRepository, dataSource }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts index d5d15d73d..0a892b108 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts @@ -59,6 +59,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo' trainSchedulingService as never, {} as never, // bookingBatchService {} as never, // bookingTransitionService + {} as never, // consolidationApprovalService ); return { service, bookingsRepository, invoiceService }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index ddd85fc48..9e31aff34 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -20,6 +20,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingTransitionService } from '../bookings/booking-transition.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ConsolidationService } from '../bookings/consolidation.service'; +import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; @@ -130,6 +131,8 @@ export class ContractBookingService { private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingTransitionService)) private readonly bookingTransitionService: BookingTransitionService, + @Inject(forwardRef(() => ConsolidationApprovalService)) + private readonly consolidationApprovalService: ConsolidationApprovalService, ) {} async createUnderContract( @@ -674,6 +677,8 @@ export class ContractBookingService { bookingId: string, dto: CompleteConsolidatedPairDto, actorPermissions?: unknown, + /** IAM id of the GL user creating the pairing — recorded on the approval. */ + actorUserId?: string | null, ): Promise<{ booking: Booking; partner: Booking; @@ -736,6 +741,16 @@ export class ContractBookingService { return { ownId: own.booking.id, partnerId: other.booking.id }; }); + // Both halves have just been completed into the operations queue by the + // ordinary completion machine. A shared wagon does not go there unreviewed: + // pull the pair back into the approval gate, which releases them to + // Operations only once a person signs off on the pairing. + await this.consolidationApprovalService.requestApproval( + ownId, + partnerId, + actorUserId ?? null, + ); + // Sequential reads: one connection per transaction context. const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId); const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 76106a9ff..826c7fdff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -358,32 +358,39 @@ export class ContractClearanceService { // Once GL creates the shipment booking, surface its reference + status so the // customer sees the concrete booking instead of a stale "will be created // shortly" message. Reuse the export booking load; fetch for import too. + let linkedBookingId: string | null = null; let linkedBookingReference: string | null = null; let linkedBookingStatus: string | null = null; let linkedBookingReviewNote: string | null = null; let linkedBookingScheduledDate: string | null = null; - if (cycle?.bookingId) { - const booking = await this.bookingsService.findById(cycle.bookingId); - if (booking) { - linkedBookingReference = booking.reference ?? null; - linkedBookingStatus = booking.status ?? null; - linkedBookingScheduledDate = booking.scheduledDate - ? new Date(booking.scheduledDate).toISOString() - : null; - // Newest changes-requested note (reviewNotes ride along on findById). - linkedBookingReviewNote = - [...(booking.reviewNotes ?? [])] - .filter((n) => n.type === 'CHANGES_REQUESTED') - .sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - )[0]?.note ?? null; - if (contract.tradeDirection === 'EXPORT') { - nextAction = this.workflowService.computeNextActionForBooking( - booking, - bookingMilestones, - ); - } + // The cycle is the historical link, but it is not written on every path (an + // FCFS export booking and a GL drawdown both reach the operations queue + // without a cycle row), so fall back to the contract's own live booking — + // otherwise the clearance page sees no linked booking at all and cannot show + // its status or the actions that depend on it. + const booking = cycle?.bookingId + ? await this.bookingsService.findById(cycle.bookingId) + : await this.contractsRepository.findLatestBookingForContract(contractId); + if (booking) { + linkedBookingId = booking.id ?? null; + linkedBookingReference = booking.reference ?? null; + linkedBookingStatus = booking.status ?? null; + linkedBookingScheduledDate = booking.scheduledDate + ? new Date(booking.scheduledDate).toISOString() + : null; + // Newest changes-requested note (reviewNotes ride along on findById). + linkedBookingReviewNote = + [...(booking.reviewNotes ?? [])] + .filter((n) => n.type === 'CHANGES_REQUESTED') + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]?.note ?? null; + if (contract.tradeDirection === 'EXPORT') { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); } } @@ -420,7 +427,7 @@ export class ContractClearanceService { bookingReady: boundary, preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), exportClearanceFinalized: Boolean(cycle?.completedAt), - linkedBookingId: cycle?.bookingId ?? null, + linkedBookingId, linkedBookingReference, linkedBookingStatus, linkedBookingReviewNote, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index e9404f8e1..9f602ec21 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1194,6 +1194,9 @@ export class ContractsController { bookingId, dto, user, + // Recorded as the requester on the approval: the person who created the + // pairing may not be the one who approves it. + user?.id ?? user?.sub ?? null, ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index d89d43250..309079010 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -656,6 +656,31 @@ export class ContractsRepository extends BaseRepository { .getCount(); } + /** + * The live shipment booking on a contract, newest first. + * + * The clearance view historically reached the booking through + * `currentCycle().bookingId`, but a cycle row is not created on every path — + * an FCFS export booking and a GL drawdown both reach + * OPERATION_REQUEST_PENDING without one — so that lookup returns null and the + * clearance page loses the booking's status entirely. This resolves it from + * the bookings themselves, which is the authoritative link (bookings carry + * contract_id), and is used as the fallback when the cycle has no booking. + */ + async findLatestBookingForContract( + contractId: string, + ): Promise { + return this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .where('b.contract_id = :contractId', { contractId }) + .andWhere('b.status NOT IN (:...terminal)', { + terminal: TERMINAL_BOOKING_STATUSES, + }) + .orderBy('b.created_at', 'DESC') + .getOne(); + } + async createReviewNote( contractId: string, body: string, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 87ba8cf8a..e9c0d453a 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -226,6 +226,14 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:bookings:wagon_cancellation_rebook", "Rebook cancelled wagons for a customer", ), + // Shared-wagon gate: two customers' cargo on one wagon is a commercial call, + // so it is signed off separately from the ordinary booking approvals — and + // never by the GL user who created the pairing. + perm( + "a1000001-0001-4000-8000-000000000029", + "edr_freight_app:bookings:approve_consolidation", + "Approve shared-wagon consolidation", + ), ]; /** @@ -1754,6 +1762,7 @@ export const FREIGHT_PERMS = { wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationRebook: "edr_freight_app:bookings:wagon_cancellation_rebook", + approveConsolidation: "edr_freight_app:bookings:approve_consolidation", // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:bookings:get_notification", clearanceGetNotification: @@ -2354,6 +2363,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reject, FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationVoid, @@ -2410,6 +2423,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveDirector, @@ -2422,6 +2439,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.approveCeo, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), @@ -2500,6 +2521,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reject, FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationVoid, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f07ec32b5..d303e3b77 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -19,6 +19,7 @@ import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import ConsolidationApprovalsPage from "./pages/bookings/ConsolidationApprovalsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; @@ -381,6 +382,18 @@ const App = () => { } /> + {/* Shared-wagon gate: consolidated pairs wait for a human decision + before either half reaches Operations. */} + + + + } + /> = { + PENDING: "yellow", + APPROVED: "teal", + REJECTED: "red", +}; + +/** + * Audit trail for this booking's shared wagon: every approval request against + * it, who decided, when, and why. Rendered only for a booking that is actually + * consolidated — there is nothing to show otherwise. + */ +export function ConsolidationApprovalCard({ bookingId }: { bookingId: string }) { + const { data } = useQuery({ + queryKey: ["consolidation-approvals", "history", bookingId], + queryFn: () => bookingsService.consolidationApprovalHistory(bookingId), + enabled: Boolean(bookingId), + }); + + if (!data?.length) return null; + + return ( + + + {data.map((row) => ( + + + + {row.status} + + + {row.bookingReference ?? "—"} + {row.partnerBookingReference ?? "—"} + + + + + Requested {formatDateTime(row.requestedAt)} + {row.requestedBy ? ` by ${row.requestedBy}` : ""} + + + {row.decidedAt ? ( + + {row.status === "APPROVED" ? "Approved" : "Rejected"}{" "} + {formatDateTime(row.decidedAt)} + {row.decidedBy ? ` by ${row.decidedBy}` : ""} + + ) : ( + + Waiting for a decision — neither booking reaches Operations until + this is approved. + + )} + + {row.decisionNote ? ( + + “{row.decisionNote}” + + ) : null} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx index 034b4c0bb..7077f3fdf 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx @@ -14,7 +14,7 @@ import { import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; import { MapPin, Plus, Search } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; const PAGE_SIZE = 20; @@ -24,7 +24,7 @@ import { api } from "@/services/api"; * AVAILABLE, unassigned wagons from every yard — filtered and paged on the API, * so the picker never page-walks the whole fleet into the browser. */ -export default function AvailableWagonsPanel({ +function AvailableWagonsPanel({ homeYardId, onAssign, assigning, @@ -36,7 +36,7 @@ export default function AvailableWagonsPanel({ const [typeFilter, setTypeFilter] = useState("ALL"); const [yardFilter, setYardFilter] = useState("ALL"); const [runOnly, setRunOnly] = useState(false); - const [selected, setSelected] = useState([]); + const [selected, setSelected] = useState>(() => new Set()); const [page, setPage] = useState(1); // The train's own run, e.g. "8001-8002" — only offered when the train has one. @@ -60,6 +60,9 @@ export default function AvailableWagonsPanel({ pageSize: PAGE_SIZE, }, }, + // Keep the previous page on screen while the next one loads — otherwise + // paging and typing flash the list to "Loading wagons…" on every stroke. + placeholderData: (prev) => prev, }), ); @@ -105,32 +108,32 @@ export default function AvailableWagonsPanel({ [wagonTypesQuery.data], ); - const toggle = (wagonId: string, checked: boolean) => { - setSelected((prev) => - checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId), - ); - }; + const toggle = useCallback((wagonId: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(wagonId); + else next.delete(wagonId); + return next; + }); + }, []); // Select-all covers this page only — the rest of the matches are not loaded. - const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); - const someSelected = wagons.some((w) => selected.includes(w.id)); + const allSelected = wagons.length > 0 && wagons.every((w) => selected.has(w.id)); + const someSelected = wagons.some((w) => selected.has(w.id)); const toggleAll = (checked: boolean) => { setSelected((prev) => { - if (checked) { - const ids = new Set(prev); - wagons.forEach((w) => ids.add(w.id)); - return [...ids]; - } - const visible = new Set(wagons.map((w) => w.id)); - return prev.filter((id) => !visible.has(id)); + const next = new Set(prev); + if (checked) wagons.forEach((w) => next.add(w.id)); + else wagons.forEach((w) => next.delete(w.id)); + return next; }); }; const handleAssign = () => { - if (!selected.length) return; - onAssign(selected); - setSelected([]); + if (!selected.size) return; + onAssign([...selected]); + setSelected(new Set()); }; return ( @@ -178,16 +181,24 @@ export default function AvailableWagonsPanel({ indeterminate={!allSelected && someSelected} onChange={(e) => toggleAll(e.currentTarget.checked)} /> - {selected.length ? ( + {selected.size ? ( - {selected.length} selected + {selected.size} selected ) : null} ) : null} - + {/* Previous results stay put while the next page loads (placeholderData), + so dim them rather than blanking the list. */} + {wagonsQuery.isLoading ? ( Loading wagons… @@ -198,57 +209,14 @@ export default function AvailableWagonsPanel({ ) : ( wagons.map((wagon) => ( - - toggle(wagon.id, e.currentTarget.checked)} - aria-label={`Select wagon ${wagon.wagonNumber}`} - /> - - - - {wagon.wagonNumber} - - } - > - {wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"} - - {wagon.exportTrainNumber ? ( - - {wagon.exportTrainNumber} - {wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""} - - ) : null} - - - {wagon.wagonType - ? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap` - : "Unknown type"} - - - + wagon={wagon} + selected={selected.has(wagon.id)} + homeYardId={homeYardId} + exportTrainNumber={exportTrainNumber} + onToggle={toggle} + /> )) )} @@ -265,16 +233,19 @@ export default function AvailableWagonsPanel({ ); } +/** Memoized: the workspace re-renders on every pending mutation. */ +export default memo(AvailableWagonsPanel); + export interface AvailableWagonsPanelProps { /** The train's own yard — sorted first and highlighted; not a restriction. */ homeYardId: string | null; @@ -285,3 +256,81 @@ export interface AvailableWagonsPanelProps { /** This train's even IMPORT run — label only; the export run does the matching. */ importTrainNumber?: string | null; } + +/** + * One selectable wagon row. Memoized: the picker re-renders on every keystroke + * and every selection change, but a row only actually changes when its own + * checkbox flips — so a full page of rows stays untouched. + */ +const WagonOption = memo(function WagonOption({ + wagon, + selected, + homeYardId, + exportTrainNumber, + onToggle, +}: { + wagon: { + id: string; + wagonNumber: string; + currentYardId?: string | null; + currentYard?: { label?: string | null; code?: string | null } | null; + exportTrainNumber?: string | null; + importTrainNumber?: string | null; + wagonType?: { name?: string | null; capacityTons?: number | null } | null; + }; + selected: boolean; + homeYardId: string | null; + exportTrainNumber?: string | null; + onToggle: (wagonId: string, checked: boolean) => void; +}) { + return ( + + onToggle(wagon.id, e.currentTarget.checked)} + aria-label={`Select wagon ${wagon.wagonNumber}`} + /> + + + + {wagon.wagonNumber} + + } + > + {wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"} + + {wagon.exportTrainNumber ? ( + + {wagon.exportTrainNumber} + {wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""} + + ) : null} + + + {wagon.wagonType + ? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap` + : "Unknown type"} + + + + ); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx index 179502b2d..101176042 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx @@ -8,7 +8,7 @@ import { } from "@hello-pangea/dnd"; import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core"; import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react"; -import { type ReactNode } from "react"; +import { memo, useCallback, useMemo, type ReactNode } from "react"; import { createPortal } from "react-dom"; import type { TrainCompositionWagon } from "@/services/trainBuilder.service"; @@ -32,7 +32,7 @@ const PortalAwareRow = ({ * The train's ordered wagon consist. Drag to reorder (persisted on drop), * trash to detach a wagon back to the yard. */ -export default function ConsistWagonList({ +function ConsistWagonList({ wagons, editable, onReorder, @@ -40,7 +40,7 @@ export default function ConsistWagonList({ onMaintenance, busy = false, }: ConsistWagonListProps) { - const onDragEnd = (result: DropResult) => { + const onDragEnd = useCallback((result: DropResult) => { if (!result.destination) return; const from = result.source.index; const to = result.destination.index; @@ -49,7 +49,18 @@ export default function ConsistWagonList({ const [moved] = next.splice(from, 1); next.splice(to, 0, moved!); onReorder(next.map((w) => w.id)); - }; + }, [wagons, onReorder]); + + // Legend of the types actually coupled, in consist order — the colour code is + // only readable if the row tints are keyed somewhere. + const legend = useMemo( + () => [ + ...new Map( + wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]), + ).values(), + ], + [wagons], + ); if (!wagons.length) { return ( @@ -59,16 +70,6 @@ export default function ConsistWagonList({ ); } - // Legend of the types actually coupled, in consist order — the colour code is - // only readable if the row tints are keyed somewhere. - const legend = [ - ...new Map( - wagons - .filter((w) => w.wagonType) - .map((w) => [w.wagonType!.code, w.wagonType!]), - ).values(), - ]; - return ( @@ -118,6 +119,9 @@ export default function ConsistWagonList({ ); } +/** Memoized: a 40-wagon consist re-renders every row otherwise. */ +export default memo(ConsistWagonList); + export interface ConsistWagonListProps { wagons: TrainCompositionWagon[]; editable: boolean; @@ -128,7 +132,7 @@ export interface ConsistWagonListProps { busy?: boolean; } -function WagonRow({ +const WagonRow = memo(function WagonRow({ wagon, index, dragProvided, @@ -232,4 +236,4 @@ function WagonRow({ ); -} +}); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index 6a89af519..79bc9ece2 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react"; @@ -132,7 +132,7 @@ function Coupler() { ); } -function LocomotiveCar({ +const LocomotiveCar = memo(function LocomotiveCar({ code, name, maxPullWeightTons, @@ -260,7 +260,7 @@ function LocomotiveCar({ ); -} +}); const CONTAINER_GRADIENTS = [ "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", @@ -271,7 +271,7 @@ const CONTAINER_BORDERS = [ "var(--mantine-color-blue-8)", ]; -function WagonCar({ wagon }: { wagon: NormalizedWagon }) { +const WagonCar = memo(function WagonCar({ wagon }: { wagon: NormalizedWagon }) { // GROSS on both sides: cargo + tare vs rated payload + tare. const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons); const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons); @@ -468,7 +468,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { ); -} +}); /** Railway track: two rails over evenly-spaced sleepers. */ function TrackBed() { @@ -520,7 +520,7 @@ function TrackBed() { ); } -export function TrainCompositionDiagram({ +export const TrainCompositionDiagram = memo(function TrainCompositionDiagram({ locomotive, locomotives, wagons, @@ -818,7 +818,7 @@ export function TrainCompositionDiagram({ ); -} +}); function LegendDot({ color, label }: { color: string; label: string }) { return ( diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 1415ad37b..745ab7fd8 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -193,6 +193,15 @@ export const URL_CONSTANTS = { STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`, // Consolidated pair: one staff decision applied to both halves at once. PAIRED_DECISION: (id: string) => `/bookings/${id}/paired-decision`, + // Shared-wagon approval gate: a consolidated pair waits for a human + // decision before either half reaches Operations. + CONSOLIDATION_APPROVAL_QUEUE: "/bookings/consolidation-approvals/queue", + CONSOLIDATION_APPROVAL_HISTORY: (id: string) => + `/bookings/${id}/consolidation-approvals`, + CONSOLIDATION_APPROVE: (approvalId: string) => + `/bookings/consolidation-approvals/${approvalId}/approve`, + CONSOLIDATION_REJECT: (approvalId: string) => + `/bookings/consolidation-approvals/${approvalId}/reject`, STAFF_REQUEST_CHANGES: (id: string) => `/bookings/${id}/staff/request-changes`, STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index 07dbae7ce..cd3cef570 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -102,6 +102,11 @@ export const BOOKING_STATUS_STYLES: Record = { label: "Operation Changes", color: "bg-orange-50 text-orange-700 border-orange-200", }, + // Shared-wagon gate: held for a human decision before reaching Operations. + CONSOLIDATION_APPROVAL_PENDING: { + label: "Wagon Approval", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, OPERATION_PRICE_PENDING_CONFIRM: { label: "Price Confirm", color: "bg-amber-50 text-amber-700 border-amber-200", @@ -309,6 +314,9 @@ export const BOOKING_LIST_TABS = [ "OPERATION_REQUEST_PENDING", "OPERATION_CHANGES_REQUESTED", "OPERATION_PRICE_PENDING_CONFIRM", + // Held at the shared-wagon gate — still an ops-review-stage booking, it + // just needs the pairing signed off before Operations can act on it. + "CONSOLIDATION_APPROVAL_PENDING", ], }, { diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 218989517..6dce79940 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -57,6 +57,7 @@ export const FREIGHT_PERMS = { wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationRebook: "edr_freight_app:bookings:wagon_cancellation_rebook", + approveConsolidation: "edr_freight_app:bookings:approve_consolidation", }, contracts: { view: "edr_freight_app:contracts:view", diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts new file mode 100644 index 000000000..81a91d8ee --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { queryClient } from "./queryClient"; + +/** + * The MutationCache seeds `meta.updates` entries into the cache and then skips + * exactly those keys when running `meta.invalidates`. Getting that skip wrong + * silently reintroduces the refetch it exists to avoid, so it is worth pinning. + */ +const runOnSuccess = (meta: Record, data: unknown, variables: unknown) => { + const handler = (queryClient.getMutationCache() as unknown as { + config: { + onSuccess?: ( + data: unknown, + variables: unknown, + context: unknown, + mutation: { meta?: Record }, + ) => void; + }; + }).config.onSuccess; + handler?.(data, variables, undefined, { meta }); +}; + +describe("MutationCache updates/invalidates", () => { + it("writes the mutation response into the seeded key and leaves it fresh", () => { + const seededKey = ["train-builder", "composition", "t1"] as const; + const siblingKey = ["train-builder", "list", {}] as const; + + queryClient.setQueryData(seededKey, { code: "STALE" }); + queryClient.setQueryData(siblingKey, { items: [] }); + + const response = { code: "FRESH" }; + runOnSuccess( + { + updates: (_v: unknown, d: unknown) => [[seededKey, d]], + invalidates: () => [["train-builder"]], + }, + response, + { id: "t1" }, + ); + + // Seeded key holds the response, and was NOT invalidated back to stale. + expect(queryClient.getQueryData(seededKey)).toStrictEqual(response); + expect(queryClient.getQueryState(seededKey)?.isInvalidated).toBe(false); + + // Its siblings under the same root still get invalidated. + expect(queryClient.getQueryState(siblingKey)?.isInvalidated).toBe(true); + }); + + it("invalidates everything when a mutation declares no updates", () => { + const key = ["train-builder", "composition", "t2"] as const; + queryClient.setQueryData(key, { code: "X" }); + + runOnSuccess({ invalidates: () => [["train-builder"]] }, undefined, undefined); + + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index ee072b330..e372e3255 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -1,6 +1,6 @@ import { MutationCache, QueryClient } from "@tanstack/react-query"; -import type { InvalidatesMeta } from "@/utils/endpoint"; +import type { InvalidatesMeta, UpdatesMeta } from "@/utils/endpoint"; /** * Single app-wide React Query client (do not nest additional providers). @@ -14,13 +14,37 @@ import type { InvalidatesMeta } from "@/utils/endpoint"; export const queryClient = new QueryClient({ mutationCache: new MutationCache({ onSuccess: (data, variables, _context, mutation) => { + // Seed first: endpoints that return the entity they just changed write it + // straight into its cache key, so the screen updates from the response + // instead of round-tripping for data it already holds. + const updates = mutation.meta?.updates as UpdatesMeta | undefined; + const seeded: readonly unknown[][] = []; + if (typeof updates === "function") { + for (const [queryKey, value] of updates(variables, data)) { + queryClient.setQueryData(queryKey, value); + seeded.push(queryKey as unknown[]); + } + } + const invalidates = mutation.meta?.invalidates as | InvalidatesMeta | undefined; if (typeof invalidates !== "function") return; for (const queryKey of invalidates(variables, data)) { - void queryClient.invalidateQueries({ queryKey }); + void queryClient.invalidateQueries({ + queryKey, + // A seeded key already holds the authoritative value from this very + // response — invalidating it would refetch it right back. + predicate: seeded.length + ? (query) => + !seeded.some( + (key) => + key.length === query.queryKey.length && + key.every((part, i) => Object.is(part, query.queryKey[i])), + ) + : undefined, + }); } }, // Mutation failures are surfaced globally by the axios interceptor in diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 2444899dd..f0783ab82 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { ActionIcon, + Alert, Badge, Box, Button, @@ -48,6 +49,7 @@ import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge import { NextStepBanner } from "@/components/bookings/NextStepBanner"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner"; +import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard"; import { detailStyles, BookingRouteServiceCard, @@ -460,6 +462,21 @@ export default function BookingRequestDetailPage() { )} + {booking.status === "CONSOLIDATION_APPROVAL_PENDING" && ( + } + title="Waiting for shared-wagon approval" + > + + This booking shares a wagon with another customer's booking. + Both are held here until the pairing is approved — neither reaches + Operations before then. + + + )} + {/* LEFT — primary content, split into tabs to keep each view focused. The Documents tab is always present, so the tab bar always renders. */} @@ -534,6 +551,8 @@ export default function BookingRequestDetailPage() { that train and its clock must be readable before the approve button. */} + {/* Renders itself only when this booking has a shared wagon. */} + (null); + const [note, setNote] = useState(""); + + const { + data: rows, + isLoading, + isError, + } = useQuery({ + queryKey: QUEUE_KEY, + queryFn: () => bookingsService.consolidationApprovalQueue(), + }); + + const close = () => { + setDecision(null); + setNote(""); + }; + + const decide = useMutation({ + mutationFn: () => { + if (!decision) throw new Error("No pairing selected"); + return decision.kind === "approve" + ? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined) + : bookingsService.rejectConsolidation(decision.row.id, note.trim()); + }, + onSuccess: () => { + toast.success( + decision?.kind === "approve" + ? "Shared wagon approved — both bookings sent to Operations" + : "Shared wagon rejected — both bookings returned to GL", + ); + void qc.invalidateQueries({ queryKey: QUEUE_KEY }); + close(); + }, + onError: (error) => + toast.error(extractErrorMessage(error, "Could not record the decision")), + }); + + // A rejection has to tell GL what to fix, so the reason is mandatory there. + const confirmDisabled = + decide.isPending || (decision?.kind === "reject" && !note.trim()); + + return ( + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( + }> + 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)}` + : ""} + + + + + + + + + + + ))} + + )} + + { + if (!decide.isPending) close(); + }} + centered + radius="lg" + title={ + + {decision?.kind === "approve" + ? "Approve this shared wagon?" + : "Reject 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."} + + +