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 ? (
+
+ }
+ onClick={() => setPartnerPickerOpen(true)}
+ >
+ {partner
+ ? `Parent booking: ${partner.reference} — change`
+ : "Parent booking"}
+
+ {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({
}
- disabled={!selected.length}
+ disabled={!selected.size}
loading={assigning}
onClick={handleAssign}
>
- Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
+ Add {selected.size ? `${selected.size} wagon${selected.size > 1 ? "s" : ""}` : "wagons"} to consist
);
}
+/** 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)}`
+ : ""}
+
+
+
+
+
+ }
+ onClick={() => {
+ setDecision({ row, kind: "approve" });
+ setNote("");
+ }}
+ >
+ Approve
+
+ }
+ onClick={() => {
+ setDecision({ row, kind: "reject" });
+ setNote("");
+ }}
+ >
+ Reject
+
+
+
+
+ ))}
+
+ )}
+
+ {
+ 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."}
+
+
+
+
+
+ );
+}
+
+/** One half of the wagon: its reference (linked) and whose cargo it is. */
+function BookingSide({
+ id,
+ reference,
+ company,
+}: {
+ id: string;
+ reference?: string | null;
+ company?: string | null;
+}) {
+ return (
+
+
+ {reference ?? "—"}
+
+
+ {company ?? "—"}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
index af648d509..69a4edb4b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
@@ -29,7 +29,7 @@ import {
Weight,
Wrench,
} from "lucide-react";
-import { useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
@@ -98,7 +98,14 @@ export default function TrainBuilderDetailPage() {
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
const compositionQuery = useQuery(
- api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
+ api.trainBuilder.composition.queryOptions({
+ input: { id },
+ enabled: Boolean(id),
+ // Mutations seed this key from their own response (see `seedComposition`
+ // in services/api.ts), so the cached consist is authoritative — the
+ // global staleTime of 0 would otherwise refetch it on every remount.
+ staleTime: 30_000,
+ }),
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
@@ -111,6 +118,34 @@ export default function TrainBuilderDetailPage() {
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
+
+ // The diagram memoizes off its `locomotives`/`wagons` props; building those
+ // arrays inline in JSX would hand it a new identity on every render and
+ // re-normalize + repaint every car for each keystroke or pending mutation.
+ const diagramLocomotives = useMemo(
+ () =>
+ (composition?.locomotives ?? []).map((loco) => ({
+ code: loco.code,
+ name: loco.name,
+ maxPullWeightTons: loco.maxPullWeightTons,
+ })),
+ [composition?.locomotives],
+ );
+ const diagramWagons = useMemo(
+ () =>
+ (composition?.wagons ?? []).map((wagon, index) => ({
+ sequenceNo: wagon.sequenceNumber ?? index + 1,
+ capacityTons: wagon.wagonType?.capacityTons ?? 0,
+ // No bookings at build time — wagons ride empty until allocation.
+ assignedWeightTons: 0,
+ tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
+ wagonTypeCode: wagon.wagonType?.code ?? null,
+ physicalWagonNumber: wagon.wagonNumber,
+ allocations: [],
+ })),
+ [composition?.wagons],
+ );
+
// Staff identify a train by its operational run numbers, not the internal
// code — mirrors formatTrainRunLabel on the API, which writes the history note.
const trainRunLabel =
@@ -130,17 +165,62 @@ export default function TrainBuilderDetailPage() {
maintenanceWagon.isPending ||
reorderWagons.isPending;
- const withToast = async (action: () => Promise, failTitle: string) => {
- try {
- await action();
- } catch (err) {
- toast({
- title: failTitle,
- description: parseError(err, "Something went wrong"),
- variant: "destructive",
- });
- }
- };
+ const withToast = useCallback(
+ async (action: () => Promise, failTitle: string) => {
+ try {
+ await action();
+ } catch (err) {
+ toast({
+ title: failTitle,
+ description: parseError(err, "Something went wrong"),
+ variant: "destructive",
+ });
+ }
+ },
+ [toast],
+ );
+
+ // Stable handlers: the consist list and wagon picker are memoized, so a new
+ // closure each render would defeat the memo and re-render every wagon row
+ // (and re-mount the drag context) on unrelated state changes.
+ // `trainId` is only absent before the composition loads, and these handlers
+ // are wired to controls that render after that — the guard keeps the promise
+ // rather than leaning on a non-null assertion.
+ const trainId = composition?.id;
+ const handleAssign = useCallback(
+ (wagonIds: string[]) => {
+ if (!trainId) return;
+ void withToast(
+ () => assignWagons.mutateAsync({ id: trainId, wagonIds }),
+ "Could not add wagons",
+ );
+ },
+ [withToast, assignWagons.mutateAsync, trainId],
+ );
+ const handleReorder = useCallback(
+ (wagonIds: string[]) => {
+ if (!trainId) return;
+ void withToast(
+ () => reorderWagons.mutateAsync({ id: trainId, wagonIds }),
+ "Could not reorder wagons",
+ );
+ },
+ [withToast, reorderWagons.mutateAsync, trainId],
+ );
+ const handleRemove = useCallback(
+ (wagonId: string) => {
+ if (!trainId) return;
+ void withToast(
+ () => removeWagon.mutateAsync({ id: trainId, wagonId }),
+ "Could not detach wagon",
+ );
+ },
+ [withToast, removeWagon.mutateAsync, trainId],
+ );
+ const handleMaintenance = useCallback(
+ (wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
+ [],
+ );
if (compositionQuery.isLoading) {
return (
@@ -364,21 +444,8 @@ export default function TrainBuilderDetailPage() {
({
- code: loco.code,
- name: loco.name,
- maxPullWeightTons: loco.maxPullWeightTons,
- }))}
- wagons={composition.wagons.map((wagon, index) => ({
- sequenceNo: wagon.sequenceNumber ?? index + 1,
- capacityTons: wagon.wagonType?.capacityTons ?? 0,
- // No bookings at build time — wagons ride empty until allocation.
- assignedWeightTons: 0,
- tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
- wagonTypeCode: wagon.wagonType?.code ?? null,
- physicalWagonNumber: wagon.wagonNumber,
- allocations: [],
- }))}
+ locomotives={diagramLocomotives}
+ wagons={diagramWagons}
trainNumber={composition.code}
totalLengthMeters={totals.totalLengthMeters}
/>
@@ -412,12 +479,7 @@ export default function TrainBuilderDetailPage() {
exportTrainNumber={composition.exportTrainNumber}
importTrainNumber={composition.importTrainNumber}
assigning={assignWagons.isPending}
- onAssign={(wagonIds) =>
- void withToast(
- () => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
- "Could not add wagons",
- )
- }
+ onAssign={handleAssign}
/>
@@ -434,19 +496,9 @@ export default function TrainBuilderDetailPage() {
wagons={composition.wagons}
editable={composition.editable && canAssign}
busy={busy}
- onReorder={(wagonIds) =>
- void withToast(
- () => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
- "Could not reorder wagons",
- )
- }
- onRemove={(wagonId) =>
- void withToast(
- () => removeWagon.mutateAsync({ id: composition.id, wagonId }),
- "Could not detach wagon",
- )
- }
- onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
+ onReorder={handleReorder}
+ onRemove={handleRemove}
+ onMaintenance={handleMaintenance}
/>
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index b68589e34..7770dbd96 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -284,6 +284,32 @@ const TRAIN_BUILDER_INVALIDATIONS: ReadonlyArray = [
QUERY_KEYS.FLEET.ROOT,
];
+/**
+ * Coupling/uncoupling wagons moves wagons between the available pool and one
+ * train — it does not touch locomotives, so those roots stay valid. Trimming
+ * the set keeps a drag-reorder from refetching the whole fleet.
+ */
+const TRAIN_BUILDER_WAGON_INVALIDATIONS: ReadonlyArray = [
+ QUERY_KEYS.TRAIN_BUILDER.ROOT,
+ QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
+ ["wagons"],
+];
+
+/**
+ * Every train-builder mutation responds with the train's full, fresh
+ * composition — write it straight into the detail cache so the workspace
+ * repaints from the response instead of refetching what it was just handed.
+ */
+const seedComposition = (
+ input: { id: string } | string,
+ data: TrainComposition,
+): ReadonlyArray => [
+ [
+ QUERY_KEYS.TRAIN_BUILDER.composition(typeof input === "string" ? input : input.id),
+ data,
+ ],
+];
+
export const api = {
trainScheduling: {
// ── Queries ────────────────────────────────────────────────────────────
@@ -2070,6 +2096,7 @@ export const api = {
trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
+ seedComposition,
),
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
@@ -2079,6 +2106,7 @@ export const api = {
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
+ seedComposition,
),
updateDetails: endpoint<
@@ -2091,6 +2119,7 @@ export const api = {
trainBuilderService.updateDetails(id, payload).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
+ seedComposition,
),
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
@@ -2099,7 +2128,8 @@ export const api = {
({ id, wagonIds }) =>
trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data),
undefined,
- () => TRAIN_BUILDER_INVALIDATIONS,
+ () => TRAIN_BUILDER_WAGON_INVALIDATIONS,
+ seedComposition,
),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
@@ -2108,7 +2138,8 @@ export const api = {
({ id, wagonId }) =>
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
undefined,
- () => TRAIN_BUILDER_INVALIDATIONS,
+ () => TRAIN_BUILDER_WAGON_INVALIDATIONS,
+ seedComposition,
),
sendWagonToMaintenance: endpoint<
@@ -2120,7 +2151,8 @@ export const api = {
({ id, wagonId, note }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
undefined,
- () => TRAIN_BUILDER_INVALIDATIONS,
+ () => TRAIN_BUILDER_WAGON_INVALIDATIONS,
+ seedComposition,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
@@ -2129,7 +2161,8 @@ export const api = {
({ id, wagonIds }) =>
trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data),
undefined,
- () => TRAIN_BUILDER_INVALIDATIONS,
+ () => TRAIN_BUILDER_WAGON_INVALIDATIONS,
+ seedComposition,
),
deactivate: endpoint(
@@ -2138,6 +2171,7 @@ export const api = {
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
+ seedComposition,
),
activate: endpoint(
@@ -2146,6 +2180,7 @@ export const api = {
(id) => trainBuilderService.activate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
+ seedComposition,
),
disband: endpoint(
diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
index c6ba796d2..f558d8271 100644
--- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
@@ -6,6 +6,35 @@ import type { Freight } from "@edr/types";
const B = URL_CONSTANTS.BOOKINGS;
+/**
+ * One shared-wagon approval. Covers BOTH bookings on the wagon — the pair is
+ * decided as a unit, never one side at a time.
+ */
+export interface ConsolidationApprovalRow {
+ id: string;
+ bookingId: string;
+ partnerBookingId: string;
+ status: "PENDING" | "APPROVED" | "REJECTED";
+ requestedBy?: string | null;
+ requestedAt: string;
+ decidedBy?: string | null;
+ decidedAt?: string | null;
+ decisionNote?: string | null;
+ scheduledDate?: string | null;
+ bookingReference?: string | null;
+ partnerBookingReference?: string | null;
+ booking?: {
+ id: string;
+ reference?: string;
+ company?: { name?: string } | null;
+ } | null;
+ partnerBooking?: {
+ id: string;
+ reference?: string;
+ company?: { name?: string } | null;
+ } | null;
+}
+
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs */
@@ -327,6 +356,60 @@ export const bookingsService = {
cancel: (id: string, reason: string) =>
postBooking(B.CANCEL(id), { reason }),
+ // ── Shared-wagon approval gate ──────────────────────────────────────────
+
+ /** Pairings awaiting a decision, oldest first. */
+ consolidationApprovalQueue: async (): Promise => {
+ const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
+ return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
+ },
+
+ /** Decision history for one booking's shared wagon — who, when, and why. */
+ consolidationApprovalHistory: async (
+ bookingId: string,
+ ): Promise => {
+ const response = await client.get(
+ B.CONSOLIDATION_APPROVAL_HISTORY(bookingId),
+ );
+ return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
+ },
+
+ /** Approve: both bookings leave the gate and continue to Operations. */
+ approveConsolidation: async (approvalId: string, note?: string) => {
+ const response = await client.post(B.CONSOLIDATION_APPROVE(approvalId), {
+ note,
+ });
+ return unwrap(response.data);
+ },
+
+ /** Reject: both bookings go back to GL for changes with the reason. */
+ rejectConsolidation: async (approvalId: string, reason: string) => {
+ const response = await client.post(B.CONSOLIDATION_REJECT(approvalId), {
+ reason,
+ });
+ return unwrap(response.data);
+ },
+
+ /**
+ * Apply one staff decision to BOTH halves of a consolidated pair. The two
+ * bookings share a wagon, so they advance or cancel together — all-or-nothing
+ * on the server. Each half keeps its own invoice and payment.
+ */
+ pairedDecision: async (
+ id: string,
+ decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
+ options: { reason?: string; note?: string; validityDays?: number } = {},
+ ): Promise<{ booking: BookingDetail; partner: BookingDetail }> => {
+ const response = await client.post(B.PAIRED_DECISION(id), {
+ decision,
+ ...options,
+ });
+ return unwrap(response.data) as {
+ booking: BookingDetail;
+ partner: BookingDetail;
+ };
+ },
+
create: async (payload: Record): Promise => {
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
B.BASE,
diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
index 8c11af23d..7303ef977 100644
--- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
@@ -73,6 +73,32 @@ export interface ShipmentValidation {
totalAmount?: number;
}
+/**
+ * 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
+ * enters 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;
+}
+
+/** Both halves of a shared-wagon completion, each with its own full payload. */
+export interface CompleteConsolidatedPairPayload {
+ partnerBookingId: string;
+ booking: Freight.CreateBookingUnderContractDto;
+ partner: Freight.CreateBookingUnderContractDto;
+}
+
export interface ContractListSummaryMetrics {
inQueue: number;
needsAction: number;
@@ -677,6 +703,46 @@ export const contractsService = {
};
},
+ /**
+ * Bookings GL may link to an odd-20ft customs booking as its shared-wagon
+ * partner (same route and direction, customs, odd 20ft, not already paired).
+ */
+ listConsolidationCandidates: async (
+ id: string,
+ bookingId: string,
+ ): Promise => {
+ const response = await client.get(
+ C.CONSOLIDATION_CANDIDATES(id, bookingId),
+ );
+ return (unwrap(response.data) ?? []) as ConsolidationCandidate[];
+ },
+
+ /**
+ * Complete an odd-20ft booking together with the partner booking sharing its
+ * wagon. All-or-nothing on the server: either both bookings complete and are
+ * linked, or neither does. Each booking keeps its own price and its own
+ * invoice — only the wagon is shared.
+ */
+ completeConsolidatedPair: async (
+ id: string,
+ bookingId: string,
+ payload: CompleteConsolidatedPairPayload,
+ ): Promise<{
+ booking: { id: string; reference: string };
+ partner: { id: string; reference: string };
+ warnings?: string[];
+ }> => {
+ const response = await client.post(
+ C.BOOKINGS_COMPLETE_CONSOLIDATED(id, bookingId),
+ payload,
+ );
+ return unwrap(response.data) as {
+ booking: { id: string; reference: string };
+ partner: { id: string; reference: string };
+ warnings?: string[];
+ };
+ },
+
/**
* Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail +
diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts
index 510139731..d3d6c0293 100644
--- a/apps/edr-freight-web/backoffice/src/types/booking.ts
+++ b/apps/edr-freight-web/backoffice/src/types/booking.ts
@@ -296,6 +296,12 @@ export interface BookingListRow {
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
+ /**
+ * The other half of a consolidated pair, folded into this row for display.
+ * Set client-side when both halves are present in the same page of results —
+ * the list shows one row per shared wagon, not one per booking.
+ */
+ pairedWith?: BookingListRow | null;
customsClearingEnabled?: boolean;
/**
* Derived booking kind for the list "Type" column. Mirrors the server's
diff --git a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
index 02bc70e85..bc0b01cd7 100644
--- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
+++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
@@ -33,6 +33,27 @@ export type InvalidatesMeta = (
data: unknown,
) => ReadonlyArray;
+/**
+ * Cache entries a mutation can write DIRECTLY from its own response, skipping
+ * a refetch. Many endpoints already return the fresh entity they just changed
+ * (e.g. every train-builder mutation returns the whole `TrainComposition`), so
+ * re-fetching that same key is a wasted round-trip and a visible flicker.
+ *
+ * Returned pairs are written with `setQueryData` by the app-wide MutationCache
+ * BEFORE the `invalidates` keys are invalidated, and any key seeded here is
+ * skipped by that invalidation pass — the value just written IS the fresh one.
+ */
+export type UpdatesFn = (
+ input: TInput,
+ data: TResponse,
+) => ReadonlyArray;
+
+/** Shape stored in `mutation.meta.updates` and consumed by the MutationCache. */
+export type UpdatesMeta = (
+ variables: unknown,
+ data: unknown,
+) => ReadonlyArray;
+
// ---------------------------------------------------------------------------
// Endpoint interfaces
// ---------------------------------------------------------------------------
@@ -67,6 +88,7 @@ export function endpoint(
execute: (input: TInput) => Promise,
queryKeyBuilder?: (input: TInput) => readonly unknown[],
invalidates?: InvalidatesFn,
+ updates?: UpdatesFn,
) {
const buildKey = (input?: TInput): readonly unknown[] => {
if (queryKeyBuilder && input !== undefined) {
@@ -101,16 +123,30 @@ export function endpoint(
const mutationOptions = (
config?: Omit, "mutationFn">,
): UseMutationOptions => {
- const meta = invalidates
- ? {
- ...config?.meta,
- invalidates: ((variables, data) =>
- invalidates(
- variables as TInput,
- data as TResponse,
- )) satisfies InvalidatesMeta,
- }
- : config?.meta;
+ const meta =
+ invalidates || updates
+ ? {
+ ...config?.meta,
+ ...(invalidates
+ ? {
+ invalidates: ((variables, data) =>
+ invalidates(
+ variables as TInput,
+ data as TResponse,
+ )) satisfies InvalidatesMeta,
+ }
+ : {}),
+ ...(updates
+ ? {
+ updates: ((variables, data) =>
+ updates(
+ variables as TInput,
+ data as TResponse,
+ )) satisfies UpdatesMeta,
+ }
+ : {}),
+ }
+ : config?.meta;
return {
...config,
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
index 001e55966..cfddea8b3 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
@@ -163,11 +163,17 @@ export function Step8Review({
.join(", ")
: "";
- // 20ft containers must pair up (two per wagon) — an odd total blocks submit.
+ // 20ft containers must pair up (two per wagon). Without customs an odd total
+ // still blocks submit — nothing downstream can place the unpaired container.
+ // With customs it is allowed: Global Logistics completes the booking and links
+ // it to another customer's odd booking so the two share the wagon, so an odd
+ // count here is only a notice.
const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } =
values.cargoType === "container"
? calcWagons(values.containers ?? [])
: { hasOddUnit: false, ft20Wagons: 0 };
+ const oddPairsViaCustoms = hasOdd20ft && Boolean(values.customsClearingEnabled);
+ const oddBlocksSubmit = hasOdd20ft && !oddPairsViaCustoms;
const isGeneralContract = values.bookingType === "general_contract";
// Both one-time and general contracts take the bulk amount from the cargo step
@@ -543,7 +549,7 @@ export function Step8Review({
- {hasOdd20ft ? (
+ {oddBlocksSubmit ? (
@@ -558,6 +564,20 @@ export function Step8Review({
+ ) : oddPairsViaCustoms ? (
+
+
+
+ Odd number of 20ft containers ({twentyFtCount})
+
+
+ 20ft containers travel two per wagon, so one of yours will
+ share a wagon with another shipment. Global Logistics
+ arranges the pairing when completing your booking — you are
+ billed only for your own containers.
+
+
+
) : noWagonForSelectedDay ? (
@@ -587,7 +607,7 @@ export function Step8Review({
leftSection={}
onClick={onSubmit}
loading={submitPending}
- disabled={submitPending || hasOdd20ft || noWagonForSelectedDay}
+ disabled={submitPending || oddBlocksSubmit || noWagonForSelectedDay}
>
Submit
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
index 9c6974641..69f15e756 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
@@ -106,14 +106,14 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
- // 20ft containers ride two per wagon, so an odd total can never be planned —
- // and GL's create-booking form blocks it too, so an odd request would only
- // dead-end there. Same even-number rule the booking forms apply.
+ // 20ft containers ride two per wagon. An odd total leaves one unpaired, which
+ // is allowed here: on a customs contract GL completes the booking and links it
+ // to another customer's odd booking so the two share the wagon. The request is
+ // therefore informational only, not a block.
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => {
- if (hasOdd20ft) return;
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
@@ -220,17 +220,17 @@ export default function NewShipmentRequestPage() {
{hasOdd20ft ? (
}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
- 20ft containers travel two per wagon, so they must be requested
- in even numbers. Please add one more 20ft container or remove
- one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
- instead of {ft20Requested}).
+ 20ft containers travel two per wagon, so one of yours will
+ share a wagon with another shipment. Global Logistics arranges
+ the pairing when completing your booking — you are billed only
+ for your own containers.
) : null}
@@ -281,7 +281,6 @@ export default function NewShipmentRequestPage() {
leftSection={}
loading={submit.isPending}
onClick={handleSubmit}
- disabled={hasOdd20ft}
>
Submit shipment request