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 030f1115f..78c07bd22 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 @@ -470,6 +470,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/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts new file mode 100644 index 000000000..91b1bfd91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -0,0 +1,132 @@ +import { BookingTransitionService } from './booking-transition.service'; +import { Booking } from './entities/booking.entity'; + +/** + * Staff decisions on a consolidated pair. Two bookings sharing a wagon must move + * together: accepting one alone would put half a wagon into the approval chain, + * and cancelling one alone would strand the other on a wagon it can no longer + * fill. All-or-nothing — if either half throws, neither booking moved. + */ +describe('BookingTransitionService — paired staff decisions', () => { + function makeService(booking: Partial) { + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking as Booking), + }; + // Runs the callback so a throw propagates, which is what the all-or-nothing + // guarantee reduces to from this service's point of view. + const dataSource = { + transaction: jest.fn(async (cb: () => Promise) => cb()), + }; + + const service = new BookingTransitionService( + {} as never, // bookingsRepository + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + {} as never, // bookingBatchService + bookingsService as never, + {} as never, // bookingClearanceService + {} as never, // workflowService + {} as never, // invoiceService + {} as never, // containerValidationService + {} as never, // notifier + {} as never, // events + undefined, // milestoneService + dataSource as never, + ); + return { service, dataSource }; + } + + const paired = { + id: 'b-1', + reference: 'BK-1', + consolidationPartnerId: 'b-2', + } as Booking; + + it('accepts both halves with the same validity window', async () => { + const { service } = makeService(paired); + const accept = jest + .spyOn(service, 'acceptIntake') + .mockImplementation(async (id) => ({ id }) as Booking); + + const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', { + validityDays: 30, + }); + + expect(accept).toHaveBeenCalledTimes(2); + expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30); + expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30); + expect(result.booking.id).toBe('b-1'); + expect(result.partner.id).toBe('b-2'); + }); + + it('cancels both halves with the same reason', async () => { + const { service } = makeService(paired); + const cancel = jest + .spyOn(service, 'cancel') + .mockImplementation(async (id) => ({ id }) as Booking); + + await service.applyPairedDecision('b-1', 'cancel', 'staff-1', { + reason: 'customer withdrew', + }); + + expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); + expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); + }); + + it('propagates a failure on the second half so neither is committed', async () => { + const { service, dataSource } = makeService(paired); + jest + .spyOn(service, 'cancel') + .mockImplementationOnce(async (id) => ({ id }) as Booking) + .mockImplementationOnce(async () => { + throw new Error('partner is already in transit'); + }); + + await expect( + service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + ).rejects.toThrow('partner is already in transit'); + + // Both halves ran inside one transaction, so the throw rolls the first back. + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + }); + + it('refuses a booking that has no partner', async () => { + const { service } = makeService({ + id: 'b-1', + consolidationPartnerId: null, + } as Booking); + + await expect( + service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + ).rejects.toThrow(/no consolidation partner/i); + }); + + it('requires a validity window to accept', async () => { + const { service } = makeService(paired); + const accept = jest.spyOn(service, 'acceptIntake'); + + await expect( + service.applyPairedDecision('b-1', 'accept', 'staff-1', {}), + ).rejects.toThrow(/validity/i); + expect(accept).not.toHaveBeenCalled(); + }); + + it('routes operationAccept through the operation review on both halves', async () => { + const { service } = makeService(paired); + const review = jest + .spyOn(service, 'reviewOperationRequest') + .mockImplementation(async (id) => ({ id }) as Booking); + + await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {}); + + expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', { + note: undefined, + }); + expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', { + note: undefined, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2f50d0f38..556ce0e98 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -455,6 +455,75 @@ export class BookingTransitionService { return this.cancel(bookingId, reason ?? "Customer cancelled before payment"); } + /** + * Run a staff decision across BOTH halves of a consolidated pair. + * + * Two bookings that share a wagon must move together: accepting one while the + * other stays behind would put half a wagon into the approval chain, and + * cancelling one alone would strand the other on a wagon it can no longer + * fill. All-or-nothing — if either half throws, the transaction rolls back and + * neither booking moved. + * + * Each half still runs the ordinary single-booking transition, so pricing, + * invoicing and notifications stay per booking: the customers are billed and + * notified separately, exactly as they are today. + */ + async applyPairedDecision( + bookingId: string, + decision: "accept" | "cancel" | "operationAccept" | "requestChanges", + actorId: string, + options: { reason?: string; note?: string; validityDays?: number } = {}, + ): Promise<{ booking: Booking; partner: Booking }> { + const booking = await this.bookingsService.findById(bookingId); + const partnerId = booking.consolidationPartnerId; + if (!partnerId) { + throw new BadRequestException( + "This booking has no consolidation partner — use the single-booking action.", + ); + } + + const runOne = async (id: string): Promise => { + switch (decision) { + case "accept": + // Same requirement as the single-booking accept: the approval chain + // needs a contract validity window. + if (!(Number(options.validityDays) > 0)) { + throw new BadRequestException( + "Contract validity (days) is required to accept.", + ); + } + return this.acceptIntake(id, actorId, Number(options.validityDays)); + case "cancel": + return this.cancel( + id, + options.reason ?? "Cancelled with its consolidation partner", + ); + case "operationAccept": + return this.reviewOperationRequest(id, "ACCEPT", actorId, { + note: options.note, + }); + case "requestChanges": + return this.requestChanges(id, options.note ?? "", actorId); + } + }; + + // Without a DataSource (unit tests hand-construct this service) fall back to + // running the two halves directly — the ordering guarantee still holds, only + // the rollback does not. + if (!this.dataSource) { + const own = await runOne(bookingId); + const other = await runOne(partnerId); + return { booking: own, partner: other }; + } + + return this.dataSource.transaction(async () => { + // Sequential: one connection per transaction context. + const own = await runOne(bookingId); + const other = await runOne(partnerId); + return { booking: own, partner: other }; + }); + } + async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ 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 9c4b69ad6..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,7 +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, @@ -165,6 +169,7 @@ export class BookingsController { private readonly lastMileService: LastMileService, private readonly userTradeAccessService: UserTradeAccessService, private readonly wagonCancellationService: BookingWagonCancellationService, + private readonly consolidationApprovalService: ConsolidationApprovalService, ) {} @Post() @@ -1541,6 +1546,92 @@ 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({ + summary: + "Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", + }) + async pairedDecision( + @Param("id", ParseUUIDPipe) id: string, + @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 }, + ); + // Sequential enrichment: both go back so the UI can refresh either tab. + const enrichedBooking = + await this.transitionService.enrichBookingResponse(booking); + const enrichedPartner = + await this.transitionService.enrichBookingResponse(partner); + return { booking: enrichedBooking, partner: enrichedPartner }; + } + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) @ApiOperation({ summary: "Cancel booking" }) 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/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 310364927..7b042e0c7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository { .find({ where: { contractId } }); } + /** + * Bookings a GL operator may manually link to `booking` as its odd-20ft + * consolidation partner (Path B customs flow). Unlike + * {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact + * quantity complement — this lists CANDIDATES for a human to choose from, so + * the filter is deliberately looser: any other customs booking on the same + * route/direction that is itself carrying an odd 20ft count. Two odd counts + * always sum to even, so any pick fills the shared wagon. + * + * Bare instances awaiting completion have no persisted containers yet, so the + * odd-count test runs on the requested container lines when they exist and the + * booking is offered as a candidate when they do not (GL enters its cargo on + * the split form). + */ + async findManualConsolidationCandidates( + booking: Booking, + limit = 50, + ): Promise { + const rows = await this.repository + .createQueryBuilder('b') + .leftJoinAndSelect('b.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('b.company', 'company') + .where('b.id != :bookingId', { bookingId: booking.id }) + // Never offer a booking that already shares a wagon with someone else. + .andWhere('b.consolidationPartnerId IS NULL') + // Customs-only: this manual flow exists because a customs (Path B) + // instance is completed by GL, not by the customer. + .andWhere('b.customsClearingEnabled = true') + // Same physical wagon ⇒ same route and same direction. + .andWhere('b.originYardId = :originYardId', { + originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { + destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { + tradeDirection: booking.tradeDirection, + }) + // Bookable = clearance finished and the booking is waiting to be completed, + // the same set completeUnderContract accepts, plus one already parked for a + // partner. + .andWhere('b.status IN (:...statuses)', { + statuses: [ + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + 'PENDING_CONSOLIDATION', + ], + }) + .orderBy('b.createdAt', 'ASC') + .take(limit) + .getMany(); + + // Odd-20ft test in memory: a bare instance has no containers yet (GL fills + // them on the split form) and stays a candidate; one that already carries + // cargo qualifies only when its 20ft total is odd. + return rows.filter((row) => { + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return true; + const ft20 = lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + return ft20 % 2 === 1; + }); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever @@ -508,6 +574,25 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** + * Link two bookings as consolidation partners WITHOUT touching their statuses. + * Used by the manual GL pairing, where both bookings have just been completed + * into their live status — unlike {@link pairConsolidation}, which exists to + * resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part + * of that resume. + */ + async linkConsolidationPartners( + bookingId: string, + partnerId: string, + ): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: partnerId, + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: bookingId, + } as never); + } + /** Un-pair a consolidation. */ async unpairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { 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 63ad5b9f4..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 @@ -125,3 +125,59 @@ export class OperationReviewDto { @IsString() note?: string; } + +/** + * A staff decision applied to BOTH halves of a consolidated pair. The two + * bookings share a wagon, so they advance or cancel together — never one alone. + */ +export class PairedDecisionDto { + @ApiProperty({ + enum: ["accept", "cancel", "operationAccept", "requestChanges"], + description: 'Which staff decision to apply to both bookings.', + }) + @IsIn(["accept", "cancel", "operationAccept", "requestChanges"]) + decision!: "accept" | "cancel" | "operationAccept" | "requestChanges"; + + @ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." }) + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional({ + description: "Message to the customer (decision=requestChanges).", + }) + @IsOptional() + @IsString() + note?: string; + + @ApiPropertyOptional({ + description: "Contract validity window in days (decision=accept).", + }) + @IsOptional() + @IsInt() + @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 new file mode 100644 index 000000000..49e77627f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -0,0 +1,216 @@ +import { ContractBookingService } from './contract-booking.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes + * the booking, so GL also picks who shares its wagon: two bookings each carrying + * an odd 20ft count are completed together onto one wagon. + * + * The two invariants that matter are that the pair is all-or-nothing (a failure + * on either half must leave NEITHER booking completed and no link written) and + * that the two bookings stay financially separate — one completion each, so one + * price and one invoice each. + */ +describe('ContractBookingService — manual odd-20ft consolidation', () => { + function makeService(overrides: { + bookingsRepository?: Partial>; + dataSource?: unknown; + }) { + const bookingsRepository = { + findByIdWithFiles: jest.fn(), + findManualConsolidationCandidates: jest.fn().mockResolvedValue([]), + linkConsolidationPartners: jest.fn().mockResolvedValue(undefined), + ...overrides.bookingsRepository, + }; + + // A transaction that simply runs the callback — enough to assert the + // all-or-nothing contract: whatever throws inside propagates out, and the + // caller observes no link written. + const dataSource = overrides.dataSource ?? { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb({})), + }; + + const service = new ContractBookingService( + { findByIdWithRelations: jest.fn() } as never, + bookingsRepository as never, + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + {} as never, // invoiceService + {} as never, // bookingNotifier + dataSource as never, + {} 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 }; + } + + const partnerBooking = { + id: 'b-2', + reference: 'BK-2', + contractId: 'c-2', + consolidationPartnerId: null, + } as unknown as Booking; + + const pairDto = { + partnerBookingId: 'b-2', + booking: { scheduledDate: '2026-09-01' }, + partner: { scheduledDate: '2026-09-01' }, + }; + + it('completes both halves and links them', async () => { + const { service, bookingsRepository } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest + .fn() + // partner lookup before the transaction + .mockResolvedValueOnce(partnerBooking) + // the two reloads after it + .mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking) + .mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking), + }, + }); + + // Each half runs the ordinary completion machine — one call per booking, so + // each is priced and invoiced on its own. + const complete = jest + .spyOn(service, 'completeUnderContract') + .mockImplementation( + async (_contractId, bookingId) => + ({ + booking: { id: bookingId } as Booking, + warnings: [], + }) as never, + ); + + const result = await service.completeConsolidatedPair( + 'c-1', + 'b-1', + pairDto as never, + ); + + expect(complete).toHaveBeenCalledTimes(2); + // The partner is completed against ITS OWN contract, not this one. + expect(complete.mock.calls[0][0]).toBe('c-1'); + expect(complete.mock.calls[1][0]).toBe('c-2'); + // Neither half may re-enter the automatic matcher — GL links them here. + expect(complete.mock.calls[0][2]).toMatchObject({ + skipAutoConsolidation: true, + }); + expect(complete.mock.calls[1][2]).toMatchObject({ + skipAutoConsolidation: true, + }); + expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith( + 'b-1', + 'b-2', + ); + expect(result.booking.id).toBe('b-1'); + expect(result.partner.id).toBe('b-2'); + }); + + it('links nothing when the partner half fails (all-or-nothing)', async () => { + const { service, bookingsRepository } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking), + }, + }); + + jest + .spyOn(service, 'completeUnderContract') + .mockImplementationOnce( + async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never, + ) + .mockImplementationOnce(async () => { + throw new Error('no train space for the partner'); + }); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', pairDto as never), + ).rejects.toThrow('no train space for the partner'); + + // The link is the last write in the transaction — it must never happen when + // a half failed, so the rollback leaves no dangling pairing. + expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled(); + }); + + it('refuses a partner that already shares a wagon', async () => { + const { service } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue({ + ...partnerBooking, + consolidationPartnerId: 'b-9', + }), + }, + }); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', pairDto as never), + ).rejects.toThrow(/already shares a wagon/i); + }); + + it('refuses to consolidate a booking with itself', async () => { + const { service } = makeService({}); + + await expect( + service.completeConsolidatedPair('c-1', 'b-1', { + ...pairDto, + partnerBookingId: 'b-1', + } as never), + ).rejects.toThrow(/cannot be consolidated with itself/i); + }); + + it('offers only bookings whose own 20ft count is odd', async () => { + // Two odd counts always sum to even, so an odd partner is exactly what fills + // the wagon; an even one would leave the pair partial again. + const rows = [ + { + id: 'odd', + reference: 'BK-ODD', + bookingContainers: [ + { quantity: 3, containerType: { sizeFt: 20 } }, + ], + }, + { + id: 'even', + reference: 'BK-EVEN', + bookingContainers: [ + { quantity: 4, containerType: { sizeFt: 20 } }, + ], + }, + // A bare instance has no cargo yet — GL enters it on the split form, so it + // stays a candidate. + { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, + ]; + + const { service } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest + .fn() + .mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking), + findManualConsolidationCandidates: jest.fn(async (booking: Booking) => + // Mirror the repository's in-memory odd filter. + rows.filter((row) => { + void booking; + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return true; + const ft20 = lines + .filter((l) => Number(l.containerType?.sizeFt) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + return ft20 % 2 === 1; + }), + ), + }, + }); + + const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); + expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']); + expect(candidates[0].ft20Quantity).toBe(3); + expect(candidates[1].hasCargo).toBe(false); + }); +}); 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 3d362d53f..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'; @@ -44,6 +45,7 @@ import { import { ClearanceMilestoneService } from './clearance-milestone.service'; import { isEffectivelyExpired } from './utils/contract-expiry.util'; import { + CompleteConsolidatedPairDto, CreateBookingContainerLineDto, CreateBookingUnderContractDto, } from './dto/create-booking-under-contract.dto'; @@ -62,6 +64,25 @@ export interface CreateBookingUnderContractResult { warnings: string[]; } +/** + * A booking GL may pick as the shared-wagon partner of an odd-20ft customs + * booking. `hasCargo` is false for a bare instance whose containers GL still has + * to enter on the split completion form. + */ +export interface ConsolidationCandidate { + id: string; + reference: string; + contractId: string | null; + companyName: string | null; + status: string; + tradeDirection: string | null; + originYardId: string | null; + destinationYardId: string | null; + scheduledDate: string | null; + ft20Quantity: number; + hasCargo: boolean; +} + /** * Outstanding split remainder of a contract: what was booked in the first split * booking's pre-split snapshot MINUS everything currently booked. Container @@ -110,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( @@ -598,6 +621,146 @@ export class ContractBookingService { return created; } + /** + * Candidate partners a GL operator may link to an odd-20ft customs booking. + * Manual counterpart to the automatic pairing in {@link consolidateDrawdown} — + * a customs instance is completed by GL, so GL also chooses who shares its + * wagon rather than waiting for the auto-matcher to find an exact complement. + */ + async listConsolidationCandidates( + contractId: string, + bookingId: string, + ): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking || booking.contractId !== contractId) { + throw new NotFoundException(`Booking ${bookingId} not found on this contract`); + } + + const rows = await this.bookingsRepository.findManualConsolidationCandidates( + booking, + ); + return rows.map((row) => { + const lines = row.bookingContainers ?? []; + return { + id: row.id, + reference: row.reference, + contractId: row.contractId ?? null, + companyName: row.company?.name ?? null, + status: row.status, + tradeDirection: row.tradeDirection ?? null, + originYardId: row.originYardId ?? null, + destinationYardId: row.destinationYardId ?? null, + scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null, + ft20Quantity: lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0), + hasCargo: lines.length > 0, + }; + }); + } + + /** + * Complete an odd-20ft customs booking together with the partner booking GL + * picked for its shared wagon. Both halves run the ordinary + * {@link completeUnderContract} machine — same gates, same pricing, same + * per-booking invoice, so each customer still pays only its own shipment — and + * are linked as consolidation partners at the end. + * + * All-or-nothing: the two completions plus the pairing run inside one + * transaction, so a failure on either half leaves neither booking completed + * and no half-linked wagon behind. `runInTransaction` is used rather than a + * manual QueryRunner so the nested services join the same transactional + * context through the shared DataSource. + */ + async completeConsolidatedPair( + contractId: string, + 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; + warnings: string[]; + }> { + if (dto.partnerBookingId === bookingId) { + throw new BadRequestException( + 'A booking cannot be consolidated with itself.', + ); + } + + const partner = await this.bookingsRepository.findByIdWithFiles( + dto.partnerBookingId, + ); + if (!partner) { + throw new NotFoundException( + `Partner booking ${dto.partnerBookingId} not found`, + ); + } + if (partner.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} already shares a wagon with another booking.`, + ); + } + if (!partner.contractId) { + throw new BadRequestException( + `Booking ${partner.reference} is not a contract booking and cannot be completed here.`, + ); + } + + const warnings: string[] = []; + + const { ownId, partnerId } = await this.dataSource.transaction(async () => { + const own = await this.completeUnderContract( + contractId, + bookingId, + { ...dto.booking, skipAutoConsolidation: true }, + // Both halves are completed by the same GL actor that reached this + // endpoint — the customs gate in completeUnderContract re-checks it. + actorPermissions, + ); + warnings.push(...own.warnings); + + const other = await this.completeUnderContract( + partner.contractId as string, + partner.id, + { ...dto.partner, skipAutoConsolidation: true }, + actorPermissions, + ); + warnings.push(...other.warnings); + + // Link the two halves. Written directly (not via pairConsolidation) because + // both bookings have just been completed into their live status here — + // pairConsolidation exists to RESUME bookings parked in + // PENDING_CONSOLIDATION and would overwrite that status. + await this.bookingsRepository.linkConsolidationPartners( + own.booking.id, + other.booking.id, + ); + 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); + return { + booking: finalBooking!, + partner: finalPartner ?? partner, + warnings, + }; + } + /** * Complete a bare initiated booking after its per-booking clearance is * finalized (CLEARANCE_READY) or operations returned it for changes @@ -826,10 +989,18 @@ export class ContractBookingService { // exactly like a drawdown created with cargo does. The shipment day is // stored first so the pairing event can resume straight into the // operations queue. + // Customs (Path B) instances are exempt from the AUTO-matcher: GL links + // their shared wagon by hand through completeConsolidatedPair, so nothing + // may claim a partner for them behind GL's back. A customs half completed + // as part of a manual pair carries `skipAutoConsolidation`; one completed + // alone still falls through to the automatic gate below, so an odd 20ft + // booking can never proceed on a partial wagon. Non-customs drawdowns are + // unaffected. const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id); if ( withContainers && freightType === 'CONTAINER' && + !dto.skipAutoConsolidation && (await this.consolidationService.needsConsolidationFromBooking(withContainers)) ) { await this.bookingsRepository.update(booking.id, { 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 c9db49b24..9f602ec21 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -76,7 +76,10 @@ import { import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { RenewContractDto } from './dto/renew-contract.dto'; -import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CompleteConsolidatedPairDto, + CreateBookingUnderContractDto, +} from './dto/create-booking-under-contract.dto'; import { CreateBookingRequestDto, ReviewBookingRequestDto, @@ -1152,10 +1155,48 @@ export class ContractsController { // Customs (Path B) instances may only be completed by GL Ethiopia — the // service checks the actor's contracts:create_booking permission. return this.contractBookingService.completeUnderContract( + id, + bookingId, + // skipAutoConsolidation is internal to the manual pair-completion path; a + // client must never suppress the wagon gate on a lone booking. + { ...dto, skipAutoConsolidation: false }, + user, + ); + } + + @Get(':id/bookings/:bookingId/consolidation-candidates') + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ + summary: + 'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).', + }) + listConsolidationCandidates( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + ) { + return this.contractBookingService.listConsolidationCandidates(id, bookingId); + } + + @Post(':id/bookings/:bookingId/complete-consolidated') + @MixedAudience(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ + summary: + 'Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.', + }) + completeConsolidatedPair( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: CompleteConsolidatedPairDto, + @CurrentUser() user: TCurrentUser & { sub?: string }, + ) { + return this.contractBookingService.completeConsolidatedPair( id, 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/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 8c1bf763d..f50ca9d4d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, @@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto { @IsOptional() @IsString() notes?: string; + + /** + * Internal: set by the manual GL pair-completion path, never by a client. + * Suppresses the automatic wagon-consolidation gate for this completion + * because the caller links the shared wagon itself. Excluded from the public + * schema so a client cannot set it to bypass the gate on a lone booking. + */ + @ApiHideProperty() + @IsOptional() + @IsBoolean() + skipAutoConsolidation?: boolean; +} + +/** + * Complete an odd-20ft customs booking together with the partner booking GL + * picked to share its wagon. Each half carries its own full completion payload — + * the two bookings stay separately priced and separately invoiced, they only + * share the wagon. + */ +export class CompleteConsolidatedPairDto { + @ApiProperty({ + format: 'uuid', + description: 'The booking chosen to share this booking’s wagon.', + }) + @IsUUID() + partnerBookingId!: string; + + @ApiProperty({ + type: CreateBookingUnderContractDto, + description: 'Completion payload for the booking in the URL.', + }) + @ValidateNested() + @Type(() => CreateBookingUnderContractDto) + booking!: CreateBookingUnderContractDto; + + @ApiProperty({ + type: CreateBookingUnderContractDto, + description: 'Completion payload for the partner booking.', + }) + @ValidateNested() + @Type(() => CreateBookingUnderContractDto) + partner!: CreateBookingUnderContractDto; } 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 d8439558d..1e7a56094 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", + ), ]; /** @@ -1782,6 +1790,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: @@ -2390,6 +2399,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, @@ -2446,6 +2459,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, @@ -2458,6 +2475,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(), @@ -2536,6 +2557,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 84840166e..68b812f6c 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"; @@ -390,6 +391,18 @@ const App = () => { } /> + {/* Shared-wagon gate: consolidated pairs wait for a human decision + before either half reaches Operations. */} + + + + } + /> - + ); } @@ -149,7 +156,13 @@ export function BookingActionsMenu({ - + ); } @@ -158,10 +171,14 @@ function ActionDialog({ flow, pendingAction, onSuppressRowClick, + consolidationPartnerId, + consolidationPartnerReference, }: { flow: ReturnType; pendingAction: ReturnType["pendingAction"]; onSuppressRowClick?: () => void; + consolidationPartnerId?: string | null; + consolidationPartnerReference?: string | null; }) { return ( ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index 6b8e4b650..246704aca 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; +import { Link2 } from "lucide-react"; import { + Alert, Modal, Group, Stack, @@ -37,6 +39,12 @@ interface BookingConfirmDialogProps { isPending: boolean; confirmDisabled?: boolean; extra?: ReactNode; + /** + * Reference of the booking sharing this one's wagon. When set, the dialog + * warns that the decision lands on BOTH bookings — staff must not think they + * are acting on one. + */ + pairedWithReference?: string | null; } export function BookingConfirmDialog({ @@ -52,6 +60,7 @@ export function BookingConfirmDialog({ isPending, confirmDisabled = false, extra, + pairedWithReference = null, }: BookingConfirmDialogProps) { if (!action || !action.confirmTitle) return null; @@ -125,6 +134,21 @@ export function BookingConfirmDialog({ {action.confirmDescription} )} + {pairedWithReference && ( + } + > + + This applies to {pairedWithReference} as well — + the two bookings share a wagon and are decided together. If either + fails, neither changes. + + + )} {/* Body */} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx new file mode 100644 index 000000000..a5c74f52e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationApprovalCard.tsx @@ -0,0 +1,82 @@ +import { useQuery } from "@tanstack/react-query"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { Link2 } from "lucide-react"; + +import { bookingsService } from "@/services/bookings.service"; +import { formatDateTime } from "@/lib/format"; +import { SectionCard } from "./SectionCard"; + +const STATUS_COLOR: Record = { + 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/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index be7111745..24398bdf9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean { return Number.isInteger(days) && days >= 1 && days <= 365; } +/** + * Decisions that must be applied to BOTH halves of a consolidated pair. The two + * bookings share one wagon: accepting one alone would put half a wagon into the + * approval chain, and cancelling one alone would strand the other on a wagon it + * can no longer fill. + */ +const PAIRED_DECISIONS = { + accept: "accept", + cancel: "cancel", + operationAccept: "operationAccept", + requestChanges: "requestChanges", +} as const; + export function useBookingActionDialog( bookingId: string, context: BookingActionContext, @@ -52,6 +65,30 @@ export function useBookingActionDialog( const onSuccess = () => closeDialog(); + // A booking on a shared wagon routes the four pairable decisions through the + // paired endpoint, which applies them to both halves all-or-nothing. Every + // other action stays per booking. + const pairedDecision = + PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS]; + if (context.consolidationPartnerId && pairedDecision) { + if (pairedDecision === "accept") { + const days = Number(inputValue.trim()); + if (!Number.isInteger(days) || days < 1 || days > 365) return; + mutations.pairedDecision.mutate( + { decision: "accept", validityDays: days }, + { onSuccess }, + ); + return; + } + mutations.pairedDecision.mutate( + pairedDecision === "cancel" + ? { decision: "cancel", reason: inputValue.trim() } + : { decision: pairedDecision, note: inputValue.trim() }, + { onSuccess }, + ); + return; + } + switch (pendingAction.id) { case "accept": { const days = Number(inputValue.trim()); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 67e84a09f..ca55338cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ActionIcon, Alert, + Badge, Box, Button, Center, @@ -40,6 +41,7 @@ import { FileText, FileUp, Flame, + Link2, MapPin, Package, Receipt, @@ -57,7 +59,10 @@ import { import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; -import { contractsService } from "@/services/contracts.service"; +import { + contractsService, + type ConsolidationCandidate, +} from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; import { useContractCapacity, @@ -80,6 +85,18 @@ import { StepHeader, StepLabel, } from "./gl-booking-form/form-ui"; +import { + ConsolidationPartnerPanel, + emptyPartnerLine, +} from "./gl-booking-form/ConsolidationPartnerPanel"; +import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; + +/** + * Container sizes offered on the parent-booking panel. Fixed rather than taken + * from this contract's scope: the parent booking is a different customer on a + * different contract, so its sizes are its own. + */ +const PARTNER_SIZES = ["20ft", "40ft"]; /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; @@ -240,6 +257,14 @@ export default function GlCreateBookingForm() { enabled: Boolean(copyFromParam), }); + // The booking being completed — used to name the customer on the price + // confirmation when a second booking's price is shown beside it. + const { data: completeBooking } = useQuery({ + queryKey: ["gl-complete-booking", completeBookingId], + queryFn: () => bookingsService.getById(completeBookingId!), + enabled: Boolean(completeBookingId), + }); + // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. @@ -290,6 +315,18 @@ export default function GlCreateBookingForm() { const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); + // ── Odd-20ft shared wagon (customs / Path B) ────────────────────────────── + // An odd 20ft total leaves one container unpaired. On a customs contract GL + // resolves that here by linking a second booking that is also odd — two odd + // counts always sum to even — completing both together onto the shared wagon. + const [consolidateOdd, setConsolidateOdd] = useState(false); + // Set once GL flips the toggle by hand, so the auto-on effect below never + // re-opens a panel GL deliberately closed. + const consolidateTouchedRef = useRef(false); + const [partnerPickerOpen, setPartnerPickerOpen] = useState(false); + const [partner, setPartner] = useState(null); + const [partnerLines, setPartnerLines] = useState([]); + const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -834,6 +871,48 @@ export default function GlCreateBookingForm() { }, [isContainer, containerLines]); const hasOdd20ft = ft20Total % 2 === 1; + // Only a customs (Path B) instance being COMPLETED by GL can use the shared + // wagon: it is GL, not the customer, who links the two bookings. Anything else + // keeps the historical hard block on odd 20ft. + const oddConsolidationAvailable = Boolean( + completeBookingId && isContainer && contract?.customsClearingEnabled, + ); + + // Auto-on: entering an odd 20ft total opens the consolidation panel by itself, + // once. GL can still switch it off — then odd is blocked exactly as before. + useEffect(() => { + if (!oddConsolidationAvailable) return; + if (consolidateTouchedRef.current) return; + if (hasOdd20ft) setConsolidateOdd(true); + }, [oddConsolidationAvailable, hasOdd20ft]); + + // Clear the partner as soon as the panel closes or stops applying, so a + // leftover selection can never ride along into a plain single-booking submit. + useEffect(() => { + if (consolidateOdd && oddConsolidationAvailable) return; + setPartner(null); + setPartnerLines([]); + setPartnerCargoDescription(""); + }, [consolidateOdd, oddConsolidationAvailable]); + + const consolidationActive = + oddConsolidationAvailable && consolidateOdd && hasOdd20ft; + + // Once a parent booking is linked, each booking's cargo is entered under its + // own labelled heading so it is clear which containers belong to whom. + const splitView = Boolean(consolidationActive && partner); + + const candidatesQuery = useQuery({ + queryKey: ["consolidation-candidates", id, completeBookingId], + queryFn: () => + contractsService.listConsolidationCandidates( + id ?? "", + completeBookingId ?? "", + ), + enabled: + partnerPickerOpen && Boolean(id) && Boolean(completeBookingId), + }); + const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkErrors = useMemo(() => { @@ -886,7 +965,64 @@ export default function GlCreateBookingForm() { !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; + // The unpaired 20ft container is resolved by the shared wagon, so with an + // active consolidation an odd total stops being a blocker; without one it + // blocks exactly as before. + const oddBlocksSubmit = hasOdd20ft && !consolidationActive; + + // Partner side: a linked partner must be picked, carry an odd 20ft count of + // its own (odd + odd = even fills the wagon) and have complete unit details. + const partnerFt20Total = useMemo(() => { + if (!consolidationActive) return 0; + return partnerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + }, [consolidationActive, partnerLines]); + + const partnerError = useMemo(() => { + if (!consolidationActive) return undefined; + if (!partner) return "Select the booking that shares this wagon."; + const totalQty = partnerLines.reduce( + (sum, l) => sum + Math.max(0, Number(l.quantity) || 0), + 0, + ); + if (totalQty < 1) { + return `Enter the containers for ${partner.reference}.`; + } + if (partnerFt20Total % 2 === 0) { + return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`; + } + const incomplete = partnerLines.some((line) => { + const qty = Number(line.quantity || 0); + return qty >= 1 && line.units.length < qty; + }); + if (incomplete) { + return `Enter the container details for all of ${partner.reference}'s containers.`; + } + const badUnit = partnerLines.some((line) => + line.units.some( + (u) => + !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) || + !(Number(u.vgmTons) > 0), + ), + ); + if (badUnit) { + return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`; + } + if (!partnerCargoDescription.trim()) { + return `Describe the cargo carried in ${partner.reference}'s containers.`; + } + return undefined; + }, [ + consolidationActive, + partner, + partnerLines, + partnerFt20Total, + partnerCargoDescription, + ]); + + const formValid = + cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError; /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is @@ -953,6 +1089,44 @@ export default function GlCreateBookingForm() { return payload; }; + /** + * Completion DTO for the partner half of a shared wagon. Route, day and train + * are deliberately copied from THIS booking: the two bookings ride the same + * wagon, so they must ride the same train on the same day. Only the cargo and + * the billing currency belong to the partner. + */ + const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!partner || !consolidationActive) return null; + + const payload: Freight.CreateBookingUnderContractDto = { + paymentCurrency, + ...(scheduledDate + ? { scheduledDate: new Date(scheduledDate).toISOString() } + : {}), + ...(trainScheduleId ? { trainScheduleId } : {}), + ...(partnerCargoDescription.trim() + ? { cargoFreeText: partnerCargoDescription.trim() } + : {}), + containers: partnerLines + .filter((l) => Number(l.quantity) >= 1) + .map((l) => ({ + containerSize: l.containerSize, + quantity: Number(l.quantity), + hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, + reeferQuantity: Number(l.reeferQuantity || 0) || undefined, + units: l.units.map((u) => ({ + containerNumber: u.containerNumber.trim().toUpperCase(), + ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + vgmTons: Number(u.vgmTons) || 0, + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + })), + })), + }; + + return payload; + }; + // Authoritative price preview (same pricing pass the booking persists at // create): rail freight + first/last mile + overweight + every surcharge, // plus the hard-block checks (20ft pairing, max capacity, container numbers @@ -964,6 +1138,22 @@ export default function GlCreateBookingForm() { }); const validation = validateShipmentMutation.data ?? null; + // The partner is priced against ITS OWN contract, so the two totals shown in + // the confirm modal are each customer's real bill — nobody pays for the other. + const validatePartnerMutation = useMutation({ + mutationFn: (input: { + contractId: string; + bookingId: string; + dto: Freight.CreateBookingUnderContractDto; + }) => + contractsService.validateShipment( + input.contractId, + input.dto, + input.bookingId, + ), + }); + const partnerValidation = validatePartnerMutation.data ?? null; + const serverTotal = useMemo(() => { const items = validation?.lineItems; if (!items?.length) return null; @@ -1010,8 +1200,52 @@ export default function GlCreateBookingForm() { }; }, [serverTotal, priceTotal, overweightSurchargeAmount]); + const partnerTotal = useMemo(() => { + const items = partnerValidation?.lineItems; + if (!items?.length) return null; + return { + currency: partnerValidation?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [partnerValidation]); + + // The partner half must clear the same hard blocks as this one — the pair is + // booked all-or-nothing, so a block on either side blocks both. + const partnerBlockers = useMemo(() => { + if (!consolidationActive || !partnerValidation) return []; + return [ + ...(partnerValidation.pairingErrors ?? []), + ...(partnerValidation.capacityErrors ?? []), + ...(partnerValidation.containerClashErrors ?? []), + ...(partnerValidation.spaceErrors ?? []), + ]; + }, [consolidationActive, partnerValidation]); + + const completePairMutation = useMutation({ + mutationFn: (input: { + payload: Freight.CreateBookingUnderContractDto; + partnerPayload: Freight.CreateBookingUnderContractDto; + partnerBookingId: string; + }) => + contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", { + partnerBookingId: input.partnerBookingId, + booking: input.payload, + partner: input.partnerPayload, + }), + }); + const submitPending = - mutations.createBooking.isPending || mutations.completeBooking.isPending; + mutations.createBooking.isPending || + mutations.completeBooking.isPending || + completePairMutation.isPending; // Block confirm until the authoritative server price is in hand — the client // estimate is display-only; booking on it would confirm an un-validated, @@ -1023,7 +1257,13 @@ export default function GlCreateBookingForm() { capacityErrors.length > 0 || containerClashErrors.length > 0 || spaceErrors.length > 0 || - !serverTotal; + !serverTotal || + // Same bar for the shared-wagon partner: its authoritative price must be in + // hand and its own hard blocks clear before either booking is confirmed. + (consolidationActive && + (validatePartnerMutation.isPending || + !partnerTotal || + partnerBlockers.length > 0)); const openPriceModal = () => { // Surface the per-field errors (portal-parity validation) instead of @@ -1039,6 +1279,15 @@ export default function GlCreateBookingForm() { validateShipmentMutation.reset(); validateShipmentMutation.mutate(payload); } + validatePartnerMutation.reset(); + const partnerPayload = buildPartnerPayload(); + if (partnerPayload && partner?.contractId) { + validatePartnerMutation.mutate({ + contractId: partner.contractId, + bookingId: partner.id, + dto: partnerPayload, + }); + } }; const handleSubmit = () => { @@ -1054,6 +1303,25 @@ export default function GlCreateBookingForm() { const payload = buildPayload(); if (!payload) return; + // Shared wagon: both halves complete together, all-or-nothing on the server. + if (consolidationActive && partner && completeBookingId) { + // A hard block on the partner's own price preview blocks the pair. + if (partnerBlockers.length > 0) return; + const partnerPayload = buildPartnerPayload(); + if (!partnerPayload) return; + completePairMutation.mutate( + { + payload, + partnerPayload, + partnerBookingId: partner.id, + }, + { + onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), + }, + ); + return; + } + if (completeBookingId) { // Completion mode: cargo + day land on the already-cleared instance — // the request was linked and accepted at submission time. @@ -1347,6 +1615,18 @@ export default function GlCreateBookingForm() { maxRows={4} styles={fieldStyles} /> + {/* With a parent booking linked, each booking's containers are + entered in its own labelled section, one after the other. */} + {splitView ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? "—"} + + + ) : null} {containerLines.length === 0 ? ( This contract has no container sizes in scope. @@ -1526,7 +1806,71 @@ export default function GlCreateBookingForm() { )) )} - {hasOdd20ft ? ( + {hasOdd20ft && oddConsolidationAvailable ? ( + } + title={`Odd number of 20ft containers (${ft20Total})`} + > + + + 20ft containers travel two per wagon, so one container here + is unpaired. On a customs booking you can pair it with + another customer's odd booking and complete both onto the + shared wagon — each booking is still priced and invoiced + separately. + + { + consolidateTouchedRef.current = true; + setConsolidateOdd(e.currentTarget.checked); + }} + /> + {consolidateOdd ? ( + + + {partner ? ( + + ) : null} + + ) : ( + + With sharing off, book an even number of 20ft containers + — add one more or remove one (e.g. {ft20Total + 1} or{" "} + {ft20Total - 1} instead of {ft20Total}). + + )} + + + ) : hasOdd20ft ? ( ) : null} + + {splitView && partner ? ( + <> + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + Parent booking — ships on the same day and train, billed to + its own customer. + + + + ) : null} ) : ( @@ -1806,12 +2178,31 @@ export default function GlCreateBookingForm() { > Fix the highlighted fields before reviewing the price. + ) : partnerError ? ( + // The review button is disabled while the parent booking is + // incomplete, so the click that would reveal the errors never + // lands — say what is outstanding without waiting for it. + } + mb="sm" + > + {partnerError} + ) : null} {/* Mantine tooltips get no pointer events from a disabled button, so the wrapper carries the hover target. */} @@ -1821,9 +2212,11 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} onClick={openPriceModal} - // Same hard block the customer portal applies at review time — - // an unpaired 20ft can never be planned onto a wagon. - disabled={hasOdd20ft} + // An unpaired 20ft can never be planned onto a wagon — unless + // a parent booking is linked to share it, which is what + // oddBlocksSubmit accounts for. The parent's own cargo must be + // complete too, or there is nothing to price. + disabled={oddBlocksSubmit || Boolean(partnerError)} > Review price & book @@ -1833,6 +2226,24 @@ export default function GlCreateBookingForm() { + setPartnerPickerOpen(false)} + candidates={candidatesQuery.data ?? []} + isLoading={candidatesQuery.isLoading} + isError={candidatesQuery.isError} + onSelect={(candidate) => { + setPartner(candidate); + // Seed a 20ft and a 40ft line. The parent booking sits on its OWN + // contract, whose size scope need not match this one's, so the panel + // offers both sizes rather than mirroring this contract's scope; a + // size the parent does not ship is simply left at 0. + setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine)); + setPartnerCargoDescription(""); + setPartnerPickerOpen(false); + }} + /> + { @@ -1984,6 +2395,18 @@ export default function GlCreateBookingForm() { )} + {/* Whose bill this is. Only worth naming when a second booking is + on screen — on a lone booking there is nothing to confuse it with. */} + {consolidationActive && partner ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? contract.company?.name ?? "—"} + + + ) : null} {displayTotal.lines.map((line, i) => ( @@ -2028,6 +2451,123 @@ export default function GlCreateBookingForm() { + {consolidationActive && partner ? ( + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + {validatePartnerMutation.isPending ? ( + + + + Pricing the partner booking… + + + ) : partnerBlockers.length > 0 ? ( + } + title={`Cannot book ${partner.reference}`} + > + + {partnerBlockers.map((msg, i) => ( + + {msg} + + ))} + + Both bookings are confirmed together, so this must be + fixed before either can be booked. + + + + ) : partnerTotal ? ( + <> + + {partnerTotal.lines.map((line, i) => ( + + + + {line.label} + + + {line.quantity.toLocaleString()} ×{" "} + {line.unitPrice.toLocaleString()}{" "} + {partnerTotal.currency} ·{" "} + {formatRateUnit(line.unit)} + + + + {line.amount.toLocaleString()}{" "} + {partnerTotal.currency} + + + ))} + + + + + Total + + + {partnerTotal.total.toLocaleString()}{" "} + + {partnerTotal.currency} + + + + + ) : ( + + No price yet for the partner booking. + + )} + + ) : null} + + {consolidationActive && partner ? ( + } + > + + These two bookings share one wagon but stay separate: each is + invoiced to its own customer and paid separately. Confirming + books both together — if either fails, neither is booked. + + + ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx new file mode 100644 index 000000000..e52cce097 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -0,0 +1,255 @@ +import { type KeyboardEvent } from "react"; +import { + Box, + Checkbox, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; + +/** + * Container editor for the PARTNER half of a shared wagon. Deliberately a + * reduced version of the main form's editor: the partner contributes only cargo + * — route, shipment day and train are inherited from the booking it shares the + * wagon with, and hazardous/reefer/return counts are derived from the per-unit + * ticks rather than typed line totals. + */ + +export interface PartnerUnitDraft { + containerNumber: string; + sealNumber: string; + vgmTons: string; + isHazardous: boolean; + isReefer: boolean; + isReturn: boolean; +} + +export interface PartnerLineDraft { + containerSize: string; + quantity: string; + hazardousQuantity: string; + reeferQuantity: string; + returnQuantity: string; + units: PartnerUnitDraft[]; +} + +export function emptyPartnerUnit(): PartnerUnitDraft { + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }; +} + +export function emptyPartnerLine(size: string): PartnerLineDraft { + return { + containerSize: size, + quantity: "0", + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: [], + }; +} + +/** Quantities are magnitudes — swallow the minus key before it reaches the field. */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + +/** Grow or shrink a line's unit rows to match its quantity. */ +function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft { + const target = Math.max(0, Math.floor(quantity) || 0); + const units = [...line.units]; + while (units.length < target) units.push(emptyPartnerUnit()); + units.length = target; + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; +} + +interface Props { + lines: PartnerLineDraft[]; + onLinesChange: (lines: PartnerLineDraft[]) => void; + cargoDescription: string; + onCargoDescriptionChange: (value: string) => void; + /** Whether per-container hazardous / refrigerated ticks apply. */ + showHazardous: boolean; + showReefer: boolean; + /** Surface field errors only after the operator tried to continue. */ + showErrors: boolean; + error?: string; +} + +export function ConsolidationPartnerPanel({ + lines, + onLinesChange, + cargoDescription, + onCargoDescriptionChange, + showHazardous, + showReefer, + showErrors, + error, +}: Props) { + const patchLine = (index: number, patch: Partial) => { + onLinesChange( + lines.map((line, i) => (i === index ? { ...line, ...patch } : line)), + ); + }; + + const patchUnit = ( + lineIndex: number, + unitIndex: number, + patch: Partial, + ) => { + onLinesChange( + lines.map((line, i) => { + if (i !== lineIndex) return line; + const units = line.units.map((unit, u) => + u === unitIndex ? { ...unit, ...patch } : unit, + ); + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; + }), + ); + }; + + return ( + + {error && showErrors ? ( + + {error} + + ) : null} + + {lines.map((line, lineIdx) => ( + + + {line.containerSize} containers + + + patchLine(lineIdx, { quantity: e.currentTarget.value })} + // Sync off the typed value, not the captured `line` — that snapshot + // still holds the pre-edit quantity and would write it back. + onBlur={(e) => { + const typed = e.currentTarget.value; + patchLine(lineIdx, { + ...syncUnits({ ...line, quantity: typed }, Number(typed || 0)), + quantity: typed, + }); + }} + mb={12} + /> + + {line.units.map((unit, unitIdx) => ( + + + Container {unitIdx + 1} + + + + patchUnit(lineIdx, unitIdx, { + containerNumber: e.currentTarget.value.toUpperCase(), + }) + } + /> + + patchUnit(lineIdx, unitIdx, { + sealNumber: e.currentTarget.value, + }) + } + /> + 0) + ? "Required." + : undefined + } + onChange={(e) => + patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value }) + } + /> + + {showHazardous || showReefer ? ( + + {showHazardous ? ( + + patchUnit(lineIdx, unitIdx, { + isHazardous: e.currentTarget.checked, + }) + } + /> + ) : null} + {showReefer ? ( + + patchUnit(lineIdx, unitIdx, { + isReefer: e.currentTarget.checked, + }) + } + /> + ) : null} + + ) : null} + + ))} + + ))} + + onCargoDescriptionChange(e.currentTarget.value)} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx new file mode 100644 index 000000000..c20c08994 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx @@ -0,0 +1,137 @@ +import { + Alert, + Badge, + Box, + Button, + Center, + Group, + Loader, + Modal, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { AlertCircle, Link2 } from "lucide-react"; + +import type { ConsolidationCandidate } from "@/services/contracts.service"; + +/** + * Picker for the booking that shares this booking's wagon. The server has + * already narrowed the list to bookings that can legally pair — same route and + * direction, customs clearing, an odd 20ft count of their own and not already + * linked to someone else — so every row here is a valid choice. + */ +interface Props { + opened: boolean; + onClose: () => void; + candidates: ConsolidationCandidate[]; + isLoading: boolean; + isError: boolean; + onSelect: (candidate: ConsolidationCandidate) => void; +} + +export function ConsolidationPartnerPicker({ + opened, + onClose, + candidates, + isLoading, + isError, + onSelect, +}: Props) { + return ( + + + + + + + Pick the parent booking + + + Customs bookings on the same route that also carry an odd number of + 20ft containers. + + + + } + > + {isLoading ? ( +
+ +
+ ) : isError ? ( + } + > + Could not load the candidate bookings. Close this and try again. + + ) : candidates.length === 0 ? ( + } + title="No booking available to share this wagon" + > + + No other customs booking on this route currently carries an odd + number of 20ft containers. Either wait for one, or switch the + shared-wagon option off and book an even number of 20ft containers. + + + ) : ( + + {candidates.map((candidate) => ( + + + + + + {candidate.reference} + + + {candidate.status.replaceAll("_", " ")} + + + + {candidate.companyName ?? "—"} + {candidate.tradeDirection + ? ` · ${candidate.tradeDirection}` + : ""} + {" · "} + {candidate.hasCargo + ? `${candidate.ft20Quantity} × 20ft` + : "cargo not entered yet"} + + + + + + ))} + + )} + + ); +} 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 d3165f696..745ab7fd8 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -191,6 +191,17 @@ export const URL_CONSTANTS = { BY_ID: (id: string) => `/bookings/${id}`, QUEUE: (queue: string) => `/bookings/queues/${queue}`, 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`, @@ -317,6 +328,12 @@ export const URL_CONSTANTS = { AWAITING_SHIPMENT: "/contracts/awaiting-shipment", BOOKINGS_COMPLETE: (id: string, bookingId: string) => `/contracts/${id}/bookings/${bookingId}/complete`, + // Odd-20ft shared-wagon consolidation (customs/Path B): candidates GL may + // link, and the all-or-nothing completion of both halves together. + CONSOLIDATION_CANDIDATES: (id: string, bookingId: string) => + `/contracts/${id}/bookings/${bookingId}/consolidation-candidates`, + BOOKINGS_COMPLETE_CONSOLIDATED: (id: string, bookingId: string) => + `/contracts/${id}/bookings/${bookingId}/complete-consolidated`, VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, CAPACITY: (id: string) => `/contracts/${id}/capacity`, // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index 5600f6cfb..eba31130c 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -59,6 +59,9 @@ export type BookingActionContext = Pick< | "reference" | "schedulingStatus" | "customsClearingEnabled" + // Set when this booking shares a wagon: the pairable staff decisions then + // apply to both halves at once rather than to this booking alone. + | "consolidationPartnerId" >; const ALLOCATABLE_SCHEDULING_STATUSES = new Set([ 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/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index 2fce8aa8f..64e993207 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -121,7 +121,38 @@ export function useBookingMutations(bookingId: string) { onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")), }); + /** + * One staff decision applied to both halves of a consolidated pair. Both + * bookings are invalidated on success so whichever tab is open reflects the + * new state immediately. + */ + const pairedDecision = useMutation({ + mutationFn: (payload: { + decision: "accept" | "cancel" | "operationAccept" | "requestChanges"; + reason?: string; + note?: string; + validityDays?: number; + }) => { + const { decision, ...options } = payload; + return bookingsService.pairedDecision(bookingId, decision, options); + }, + onSuccess: (data) => { + toast.success("Applied to both bookings on the shared wagon"); + void invalidateBookingDetail(qc, data.booking.id); + void invalidateBookingDetail(qc, data.partner.id); + }, + onError: (error) => { + toast.error( + parseApiError(error, "Failed to apply the decision to both bookings"), + ); + // Nothing should have committed (the server runs both halves in one + // transaction), but refetch so the UI never shows a stale guess. + void invalidateBookingDetail(qc, bookingId); + }, + }); + const isPending = + pairedDecision.isPending || staffAccept.isPending || requestChanges.isPending || staffReject.isPending || @@ -134,6 +165,7 @@ export function useBookingMutations(bookingId: string) { cancel.isPending; return { + pairedDecision, staffAccept, requestChanges, staffReject, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index e4d9db576..de43dff41 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -61,6 +61,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 62c26276a..f0783ab82 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -9,6 +9,7 @@ import { FolderOpen, Layers, LayoutGrid, + Link2, Milestone, MoreHorizontal, Package, @@ -20,6 +21,7 @@ import { } from "lucide-react"; import { ActionIcon, + Alert, Badge, Box, Button, @@ -47,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, @@ -79,14 +82,50 @@ export default function BookingRequestDetailPage() { const [searchParams, setSearchParams] = useSearchParams(); // Deep-link from a warehouse fee invoice → this booking's warehouse section. useScrollToHash(); + + // Consolidated pair: `?booking=` swaps the WHOLE page over to the + // other half of the shared wagon. Everything below — KPIs, stepper, the + // overview/orders/documents/trucks sub-tabs, the action toolbar — then reads + // from the selected booking, so each half gets its own complete detail page + // under a top-level tab. The URL id stays put so Back still works. + const selectedId = searchParams.get("booking") || id; const { data: booking, isLoading, isError, refetch, isFetching, - } = useBookingDetail(id); - const mutations = useBookingMutations(id ?? ""); + } = useBookingDetail(selectedId); + const mutations = useBookingMutations(selectedId ?? ""); + + // The pair is discovered from whichever half is on screen: each booking + // carries a reference to the other. + const routeBookingId = id ?? ""; + const partnerId = booking?.consolidationPartnerId ?? null; + const isPaired = Boolean(partnerId); + const viewingPartner = selectedId !== routeBookingId; + // Tab identities: the booking named by the URL is always the first tab, the + // other half the second — regardless of which one is currently displayed. + const firstTabId = routeBookingId; + const secondTabId = viewingPartner ? selectedId : partnerId; + + // Only for the tab label (reference + customer) — the displayed half is + // loaded above. Skipped entirely when the booking is not part of a pair. + const { data: otherBooking } = useBookingDetail( + secondTabId && secondTabId !== selectedId ? secondTabId : undefined, + ); + const firstTabBooking = viewingPartner ? otherBooking : booking; + const secondTabBooking = viewingPartner ? booking : otherBooking; + + const selectBooking = (bookingId: string) => { + const next = new URLSearchParams(searchParams); + if (bookingId === routeBookingId) next.delete("booking"); + else next.set("booking", bookingId); + // Switching booking resets the sub-tab: the other half has its own content + // and may not even have the tab that was open (e.g. Orders). + next.delete("tab"); + setSearchParams(next, { replace: true }); + }; if (isLoading) { return ( @@ -349,6 +388,48 @@ export default function BookingRequestDetailPage() { /> + {/* Consolidated pair: one tab per booking, switching the ENTIRE page + below. The overview/orders/documents/trucks tabs further down are + sub-tabs of whichever booking is selected here. */} + {isPaired && secondTabId ? ( + value && selectBooking(value)} + variant="pills" + radius="md" + > + + }> + + + {firstTabBooking?.reference ?? "Booking"} + + + {firstTabBooking?.company?.name ?? "—"} + + + + }> + + + {secondTabBooking?.reference ?? "Partner booking"} + + + {secondTabBooking?.company?.name ?? "—"} + + + + + + ) : null} + + {isPaired ? ( + + These two bookings share one wagon. Accepting or cancelling applies + to both; each is invoiced and paid separately. + + ) : null} + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( @@ -381,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. */} @@ -455,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. */} + (data?.items ?? []).map(toBookingListRow), - [data?.items], - ); + const rows = useMemo(() => { + const mapped = (data?.items ?? []).map(toBookingListRow); + // Consolidated pairs share one wagon and are decided together, so they show + // as ONE row. Keep the half that appears first in the current sort and hang + // the other on it as `pairedWith`; the row renders both bookings' details + // and opens the detail page, where each half gets its own tab. + const byId = new Map(mapped.map((row) => [row.id, row])); + const absorbed = new Set(); + const merged: BookingListRow[] = []; + for (const row of mapped) { + if (absorbed.has(row.id)) continue; + const partnerId = row.consolidationPartnerId; + const partner = partnerId ? byId.get(partnerId) : undefined; + if (partner && !absorbed.has(partner.id)) { + absorbed.add(partner.id); + merged.push({ ...row, pairedWith: partner }); + continue; + } + merged.push(row); + } + return merged; + }, [data?.items]); const total = data?.total ?? 0; const hasSearch = controls.searchText.trim().length > 0; @@ -331,6 +350,22 @@ export default function BookingRequestsPage() { ) : null}

+ {/* Shared wagon: the second booking rides in the same row, so the + operator sees both customers before opening the pair. */} + {b.pairedWith ? ( +
+
+ +

+ {b.pairedWith.reference} +

+
+

+ + {b.pairedWith.customerLabel} +

+
+ ) : 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 new file mode 100644 index 000000000..1aebe90c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx @@ -0,0 +1,284 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Badge, + Box, + Button, + Center, + Group, + Loader, + Modal, + Paper, + Stack, + Text, + Textarea, + ThemeIcon, +} from "@mantine/core"; +import { AlertCircle, Check, Clock, Link2, X } from "lucide-react"; +import toast from "react-hot-toast"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { + bookingsService, + type ConsolidationApprovalRow, +} from "@/services/bookings.service"; +import { formatDateTime } from "@/lib/format"; +import { extractErrorMessage } from "@/utils/errorExtractor"; + +const QUEUE_KEY = ["consolidation-approvals", "queue"]; + +/** + * Review queue for shared-wagon pairings. + * + * A booking that fills its own wagons goes straight to Operations. A + * consolidated one waits here: two customers' cargo rides one physical wagon + * 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. + */ +export default function ConsolidationApprovalsPage() { + const qc = useQueryClient(); + const [decision, setDecision] = useState<{ + row: ConsolidationApprovalRow; + kind: "approve" | "reject"; + } | null>(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."} + + +