From 22a3fb98eec99fed9bc52595b3d5fe32e06643ca Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 18 Aug 2026 12:50:35 +0000 Subject: [PATCH] feat: Implement consolidated booking functionality - Added support for viewing and managing consolidated bookings in BookingRequestDetailPage. - Enhanced BookingRequestsPage to display paired bookings in a single row. - Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair. - Updated contracts service to include methods for manual consolidation of odd-20ft bookings. - Created new components for selecting and editing consolidation partners. - Added tests for paired decision logic and manual consolidation scenarios. - Updated UI to reflect changes in booking handling and provide user feedback for odd container counts. --- ...booking-transition.paired-decision.spec.ts | 132 ++++ .../bookings/booking-transition.service.ts | 69 +++ .../modules/bookings/bookings.controller.ts | 26 + .../modules/bookings/bookings.repository.ts | 85 +++ .../bookings/dto/request-changes.dto.ts | 33 + ...tract-booking.manual-consolidation.spec.ts | 213 +++++++ .../contracts/contract-booking.service.ts | 156 +++++ .../modules/contracts/contracts.controller.ts | 40 +- .../dto/create-booking-under-contract.dto.ts | 44 +- .../bookings/BookingActionsMenu.tsx | 32 +- .../bookings/BookingConfirmDialog.tsx | 24 + .../bookings/useBookingActionDialog.ts | 37 ++ .../contracts/GlCreateBookingForm.tsx | 566 +++++++++++++++++- .../ConsolidationPartnerPanel.tsx | 255 ++++++++ .../ConsolidationPartnerPicker.tsx | 137 +++++ .../backoffice/src/constants/URLS.ts | 8 + .../bookings/booking-actions.config.ts | 3 + .../src/hooks/bookings/useBookings.ts | 32 + .../bookings/BookingRequestDetailPage.tsx | 83 ++- .../pages/bookings/BookingRequestsPage.tsx | 43 +- .../src/services/bookings.service.ts | 20 + .../src/services/contracts.service.ts | 66 ++ .../backoffice/src/types/booking.ts | 6 + .../new-booking-form/step8-review.tsx | 26 +- .../contracts/NewShipmentRequestPage.tsx | 19 +- 25 files changed, 2121 insertions(+), 34 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx 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..c2d6465c2 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -59,6 +59,7 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, CancelBookingDto, + PairedDecisionDto, RejectBookingDto, RequestChangesDto, ReviewDocumentDto, @@ -1541,6 +1542,31 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @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.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/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 63ad5b9f4..9c4e5c944 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,36 @@ 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; +} 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..841baab31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -0,0 +1,213 @@ +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 + ); + 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.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 3d362d53f..ddd85fc48 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 @@ -44,6 +44,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 +63,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 @@ -598,6 +618,134 @@ 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, + ): 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 }; + }); + + // 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 +974,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/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index c9db49b24..e9404f8e1 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,6 +1155,41 @@ 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, 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-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index a50028e90..5b7de12d5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -38,6 +38,7 @@ export function BookingActionsMenu({ reference: row.reference, schedulingStatus: row.schedulingStatus, customsClearingEnabled: row.customsClearingEnabled, + consolidationPartnerId: row.consolidationPartnerId, }; const flow = useBookingActionDialog(row.id, context); @@ -92,7 +93,13 @@ export function BookingActionsMenu({ ); })} - + ); } @@ -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/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index be7111745..24398bdf9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean { return Number.isInteger(days) && days >= 1 && days <= 365; } +/** + * Decisions that must be applied to BOTH halves of a consolidated pair. The two + * bookings share one wagon: accepting one alone would put half a wagon into the + * approval chain, and cancelling one alone would strand the other on a wagon it + * can no longer fill. + */ +const PAIRED_DECISIONS = { + accept: "accept", + cancel: "cancel", + operationAccept: "operationAccept", + requestChanges: "requestChanges", +} as const; + export function useBookingActionDialog( bookingId: string, context: BookingActionContext, @@ -52,6 +65,30 @@ export function useBookingActionDialog( const onSuccess = () => closeDialog(); + // A booking on a shared wagon routes the four pairable decisions through the + // paired endpoint, which applies them to both halves all-or-nothing. Every + // other action stays per booking. + const pairedDecision = + PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS]; + if (context.consolidationPartnerId && pairedDecision) { + if (pairedDecision === "accept") { + const days = Number(inputValue.trim()); + if (!Number.isInteger(days) || days < 1 || days > 365) return; + mutations.pairedDecision.mutate( + { decision: "accept", validityDays: days }, + { onSuccess }, + ); + return; + } + mutations.pairedDecision.mutate( + pairedDecision === "cancel" + ? { decision: "cancel", reason: inputValue.trim() } + : { decision: pairedDecision, note: inputValue.trim() }, + { onSuccess }, + ); + return; + } + switch (pendingAction.id) { case "accept": { const days = Number(inputValue.trim()); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 67e84a09f..ca55338cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ActionIcon, Alert, + Badge, Box, Button, Center, @@ -40,6 +41,7 @@ import { FileText, FileUp, Flame, + Link2, MapPin, Package, Receipt, @@ -57,7 +59,10 @@ import { import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; -import { contractsService } from "@/services/contracts.service"; +import { + contractsService, + type ConsolidationCandidate, +} from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; import { useContractCapacity, @@ -80,6 +85,18 @@ import { StepHeader, StepLabel, } from "./gl-booking-form/form-ui"; +import { + ConsolidationPartnerPanel, + emptyPartnerLine, +} from "./gl-booking-form/ConsolidationPartnerPanel"; +import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; + +/** + * Container sizes offered on the parent-booking panel. Fixed rather than taken + * from this contract's scope: the parent booking is a different customer on a + * different contract, so its sizes are its own. + */ +const PARTNER_SIZES = ["20ft", "40ft"]; /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; @@ -240,6 +257,14 @@ export default function GlCreateBookingForm() { enabled: Boolean(copyFromParam), }); + // The booking being completed — used to name the customer on the price + // confirmation when a second booking's price is shown beside it. + const { data: completeBooking } = useQuery({ + queryKey: ["gl-complete-booking", completeBookingId], + queryFn: () => bookingsService.getById(completeBookingId!), + enabled: Boolean(completeBookingId), + }); + // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. @@ -290,6 +315,18 @@ export default function GlCreateBookingForm() { const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); + // ── Odd-20ft shared wagon (customs / Path B) ────────────────────────────── + // An odd 20ft total leaves one container unpaired. On a customs contract GL + // resolves that here by linking a second booking that is also odd — two odd + // counts always sum to even — completing both together onto the shared wagon. + const [consolidateOdd, setConsolidateOdd] = useState(false); + // Set once GL flips the toggle by hand, so the auto-on effect below never + // re-opens a panel GL deliberately closed. + const consolidateTouchedRef = useRef(false); + const [partnerPickerOpen, setPartnerPickerOpen] = useState(false); + const [partner, setPartner] = useState(null); + const [partnerLines, setPartnerLines] = useState([]); + const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -834,6 +871,48 @@ export default function GlCreateBookingForm() { }, [isContainer, containerLines]); const hasOdd20ft = ft20Total % 2 === 1; + // Only a customs (Path B) instance being COMPLETED by GL can use the shared + // wagon: it is GL, not the customer, who links the two bookings. Anything else + // keeps the historical hard block on odd 20ft. + const oddConsolidationAvailable = Boolean( + completeBookingId && isContainer && contract?.customsClearingEnabled, + ); + + // Auto-on: entering an odd 20ft total opens the consolidation panel by itself, + // once. GL can still switch it off — then odd is blocked exactly as before. + useEffect(() => { + if (!oddConsolidationAvailable) return; + if (consolidateTouchedRef.current) return; + if (hasOdd20ft) setConsolidateOdd(true); + }, [oddConsolidationAvailable, hasOdd20ft]); + + // Clear the partner as soon as the panel closes or stops applying, so a + // leftover selection can never ride along into a plain single-booking submit. + useEffect(() => { + if (consolidateOdd && oddConsolidationAvailable) return; + setPartner(null); + setPartnerLines([]); + setPartnerCargoDescription(""); + }, [consolidateOdd, oddConsolidationAvailable]); + + const consolidationActive = + oddConsolidationAvailable && consolidateOdd && hasOdd20ft; + + // Once a parent booking is linked, each booking's cargo is entered under its + // own labelled heading so it is clear which containers belong to whom. + const splitView = Boolean(consolidationActive && partner); + + const candidatesQuery = useQuery({ + queryKey: ["consolidation-candidates", id, completeBookingId], + queryFn: () => + contractsService.listConsolidationCandidates( + id ?? "", + completeBookingId ?? "", + ), + enabled: + partnerPickerOpen && Boolean(id) && Boolean(completeBookingId), + }); + const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkErrors = useMemo(() => { @@ -886,7 +965,64 @@ export default function GlCreateBookingForm() { !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; + // The unpaired 20ft container is resolved by the shared wagon, so with an + // active consolidation an odd total stops being a blocker; without one it + // blocks exactly as before. + const oddBlocksSubmit = hasOdd20ft && !consolidationActive; + + // Partner side: a linked partner must be picked, carry an odd 20ft count of + // its own (odd + odd = even fills the wagon) and have complete unit details. + const partnerFt20Total = useMemo(() => { + if (!consolidationActive) return 0; + return partnerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + }, [consolidationActive, partnerLines]); + + const partnerError = useMemo(() => { + if (!consolidationActive) return undefined; + if (!partner) return "Select the booking that shares this wagon."; + const totalQty = partnerLines.reduce( + (sum, l) => sum + Math.max(0, Number(l.quantity) || 0), + 0, + ); + if (totalQty < 1) { + return `Enter the containers for ${partner.reference}.`; + } + if (partnerFt20Total % 2 === 0) { + return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`; + } + const incomplete = partnerLines.some((line) => { + const qty = Number(line.quantity || 0); + return qty >= 1 && line.units.length < qty; + }); + if (incomplete) { + return `Enter the container details for all of ${partner.reference}'s containers.`; + } + const badUnit = partnerLines.some((line) => + line.units.some( + (u) => + !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) || + !(Number(u.vgmTons) > 0), + ), + ); + if (badUnit) { + return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`; + } + if (!partnerCargoDescription.trim()) { + return `Describe the cargo carried in ${partner.reference}'s containers.`; + } + return undefined; + }, [ + consolidationActive, + partner, + partnerLines, + partnerFt20Total, + partnerCargoDescription, + ]); + + const formValid = + cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError; /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is @@ -953,6 +1089,44 @@ export default function GlCreateBookingForm() { return payload; }; + /** + * Completion DTO for the partner half of a shared wagon. Route, day and train + * are deliberately copied from THIS booking: the two bookings ride the same + * wagon, so they must ride the same train on the same day. Only the cargo and + * the billing currency belong to the partner. + */ + const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!partner || !consolidationActive) return null; + + const payload: Freight.CreateBookingUnderContractDto = { + paymentCurrency, + ...(scheduledDate + ? { scheduledDate: new Date(scheduledDate).toISOString() } + : {}), + ...(trainScheduleId ? { trainScheduleId } : {}), + ...(partnerCargoDescription.trim() + ? { cargoFreeText: partnerCargoDescription.trim() } + : {}), + containers: partnerLines + .filter((l) => Number(l.quantity) >= 1) + .map((l) => ({ + containerSize: l.containerSize, + quantity: Number(l.quantity), + hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, + reeferQuantity: Number(l.reeferQuantity || 0) || undefined, + units: l.units.map((u) => ({ + containerNumber: u.containerNumber.trim().toUpperCase(), + ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + vgmTons: Number(u.vgmTons) || 0, + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + })), + })), + }; + + return payload; + }; + // Authoritative price preview (same pricing pass the booking persists at // create): rail freight + first/last mile + overweight + every surcharge, // plus the hard-block checks (20ft pairing, max capacity, container numbers @@ -964,6 +1138,22 @@ export default function GlCreateBookingForm() { }); const validation = validateShipmentMutation.data ?? null; + // The partner is priced against ITS OWN contract, so the two totals shown in + // the confirm modal are each customer's real bill — nobody pays for the other. + const validatePartnerMutation = useMutation({ + mutationFn: (input: { + contractId: string; + bookingId: string; + dto: Freight.CreateBookingUnderContractDto; + }) => + contractsService.validateShipment( + input.contractId, + input.dto, + input.bookingId, + ), + }); + const partnerValidation = validatePartnerMutation.data ?? null; + const serverTotal = useMemo(() => { const items = validation?.lineItems; if (!items?.length) return null; @@ -1010,8 +1200,52 @@ export default function GlCreateBookingForm() { }; }, [serverTotal, priceTotal, overweightSurchargeAmount]); + const partnerTotal = useMemo(() => { + const items = partnerValidation?.lineItems; + if (!items?.length) return null; + return { + currency: partnerValidation?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [partnerValidation]); + + // The partner half must clear the same hard blocks as this one — the pair is + // booked all-or-nothing, so a block on either side blocks both. + const partnerBlockers = useMemo(() => { + if (!consolidationActive || !partnerValidation) return []; + return [ + ...(partnerValidation.pairingErrors ?? []), + ...(partnerValidation.capacityErrors ?? []), + ...(partnerValidation.containerClashErrors ?? []), + ...(partnerValidation.spaceErrors ?? []), + ]; + }, [consolidationActive, partnerValidation]); + + const completePairMutation = useMutation({ + mutationFn: (input: { + payload: Freight.CreateBookingUnderContractDto; + partnerPayload: Freight.CreateBookingUnderContractDto; + partnerBookingId: string; + }) => + contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", { + partnerBookingId: input.partnerBookingId, + booking: input.payload, + partner: input.partnerPayload, + }), + }); + const submitPending = - mutations.createBooking.isPending || mutations.completeBooking.isPending; + mutations.createBooking.isPending || + mutations.completeBooking.isPending || + completePairMutation.isPending; // Block confirm until the authoritative server price is in hand — the client // estimate is display-only; booking on it would confirm an un-validated, @@ -1023,7 +1257,13 @@ export default function GlCreateBookingForm() { capacityErrors.length > 0 || containerClashErrors.length > 0 || spaceErrors.length > 0 || - !serverTotal; + !serverTotal || + // Same bar for the shared-wagon partner: its authoritative price must be in + // hand and its own hard blocks clear before either booking is confirmed. + (consolidationActive && + (validatePartnerMutation.isPending || + !partnerTotal || + partnerBlockers.length > 0)); const openPriceModal = () => { // Surface the per-field errors (portal-parity validation) instead of @@ -1039,6 +1279,15 @@ export default function GlCreateBookingForm() { validateShipmentMutation.reset(); validateShipmentMutation.mutate(payload); } + validatePartnerMutation.reset(); + const partnerPayload = buildPartnerPayload(); + if (partnerPayload && partner?.contractId) { + validatePartnerMutation.mutate({ + contractId: partner.contractId, + bookingId: partner.id, + dto: partnerPayload, + }); + } }; const handleSubmit = () => { @@ -1054,6 +1303,25 @@ export default function GlCreateBookingForm() { const payload = buildPayload(); if (!payload) return; + // Shared wagon: both halves complete together, all-or-nothing on the server. + if (consolidationActive && partner && completeBookingId) { + // A hard block on the partner's own price preview blocks the pair. + if (partnerBlockers.length > 0) return; + const partnerPayload = buildPartnerPayload(); + if (!partnerPayload) return; + completePairMutation.mutate( + { + payload, + partnerPayload, + partnerBookingId: partner.id, + }, + { + onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), + }, + ); + return; + } + if (completeBookingId) { // Completion mode: cargo + day land on the already-cleared instance — // the request was linked and accepted at submission time. @@ -1347,6 +1615,18 @@ export default function GlCreateBookingForm() { maxRows={4} styles={fieldStyles} /> + {/* With a parent booking linked, each booking's containers are + entered in its own labelled section, one after the other. */} + {splitView ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? "—"} + + + ) : null} {containerLines.length === 0 ? ( This contract has no container sizes in scope. @@ -1526,7 +1806,71 @@ export default function GlCreateBookingForm() { )) )} - {hasOdd20ft ? ( + {hasOdd20ft && oddConsolidationAvailable ? ( + } + title={`Odd number of 20ft containers (${ft20Total})`} + > + + + 20ft containers travel two per wagon, so one container here + is unpaired. On a customs booking you can pair it with + another customer's odd booking and complete both onto the + shared wagon — each booking is still priced and invoiced + separately. + + { + consolidateTouchedRef.current = true; + setConsolidateOdd(e.currentTarget.checked); + }} + /> + {consolidateOdd ? ( + + + {partner ? ( + + ) : null} + + ) : ( + + With sharing off, book an even number of 20ft containers + — add one more or remove one (e.g. {ft20Total + 1} or{" "} + {ft20Total - 1} instead of {ft20Total}). + + )} + + + ) : hasOdd20ft ? ( ) : null} + + {splitView && partner ? ( + <> + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + Parent booking — ships on the same day and train, billed to + its own customer. + + + + ) : null} ) : ( @@ -1806,12 +2178,31 @@ export default function GlCreateBookingForm() { > Fix the highlighted fields before reviewing the price. + ) : partnerError ? ( + // The review button is disabled while the parent booking is + // incomplete, so the click that would reveal the errors never + // lands — say what is outstanding without waiting for it. + } + mb="sm" + > + {partnerError} + ) : null} {/* Mantine tooltips get no pointer events from a disabled button, so the wrapper carries the hover target. */} @@ -1821,9 +2212,11 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} onClick={openPriceModal} - // Same hard block the customer portal applies at review time — - // an unpaired 20ft can never be planned onto a wagon. - disabled={hasOdd20ft} + // An unpaired 20ft can never be planned onto a wagon — unless + // a parent booking is linked to share it, which is what + // oddBlocksSubmit accounts for. The parent's own cargo must be + // complete too, or there is nothing to price. + disabled={oddBlocksSubmit || Boolean(partnerError)} > Review price & book @@ -1833,6 +2226,24 @@ export default function GlCreateBookingForm() { + setPartnerPickerOpen(false)} + candidates={candidatesQuery.data ?? []} + isLoading={candidatesQuery.isLoading} + isError={candidatesQuery.isError} + onSelect={(candidate) => { + setPartner(candidate); + // Seed a 20ft and a 40ft line. The parent booking sits on its OWN + // contract, whose size scope need not match this one's, so the panel + // offers both sizes rather than mirroring this contract's scope; a + // size the parent does not ship is simply left at 0. + setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine)); + setPartnerCargoDescription(""); + setPartnerPickerOpen(false); + }} + /> + { @@ -1984,6 +2395,18 @@ export default function GlCreateBookingForm() { )} + {/* Whose bill this is. Only worth naming when a second booking is + on screen — on a lone booking there is nothing to confuse it with. */} + {consolidationActive && partner ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? contract.company?.name ?? "—"} + + + ) : null} {displayTotal.lines.map((line, i) => ( @@ -2028,6 +2451,123 @@ export default function GlCreateBookingForm() { + {consolidationActive && partner ? ( + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + {validatePartnerMutation.isPending ? ( + + + + Pricing the partner booking… + + + ) : partnerBlockers.length > 0 ? ( + } + title={`Cannot book ${partner.reference}`} + > + + {partnerBlockers.map((msg, i) => ( + + {msg} + + ))} + + Both bookings are confirmed together, so this must be + fixed before either can be booked. + + + + ) : partnerTotal ? ( + <> + + {partnerTotal.lines.map((line, i) => ( + + + + {line.label} + + + {line.quantity.toLocaleString()} ×{" "} + {line.unitPrice.toLocaleString()}{" "} + {partnerTotal.currency} ·{" "} + {formatRateUnit(line.unit)} + + + + {line.amount.toLocaleString()}{" "} + {partnerTotal.currency} + + + ))} + + + + + Total + + + {partnerTotal.total.toLocaleString()}{" "} + + {partnerTotal.currency} + + + + + ) : ( + + No price yet for the partner booking. + + )} + + ) : null} + + {consolidationActive && partner ? ( + } + > + + These two bookings share one wagon but stay separate: each is + invoiced to its own customer and paid separately. Confirming + books both together — if either fails, neither is booked. + + + ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx new file mode 100644 index 000000000..e52cce097 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -0,0 +1,255 @@ +import { type KeyboardEvent } from "react"; +import { + Box, + Checkbox, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; + +/** + * Container editor for the PARTNER half of a shared wagon. Deliberately a + * reduced version of the main form's editor: the partner contributes only cargo + * — route, shipment day and train are inherited from the booking it shares the + * wagon with, and hazardous/reefer/return counts are derived from the per-unit + * ticks rather than typed line totals. + */ + +export interface PartnerUnitDraft { + containerNumber: string; + sealNumber: string; + vgmTons: string; + isHazardous: boolean; + isReefer: boolean; + isReturn: boolean; +} + +export interface PartnerLineDraft { + containerSize: string; + quantity: string; + hazardousQuantity: string; + reeferQuantity: string; + returnQuantity: string; + units: PartnerUnitDraft[]; +} + +export function emptyPartnerUnit(): PartnerUnitDraft { + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }; +} + +export function emptyPartnerLine(size: string): PartnerLineDraft { + return { + containerSize: size, + quantity: "0", + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: [], + }; +} + +/** Quantities are magnitudes — swallow the minus key before it reaches the field. */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + +/** Grow or shrink a line's unit rows to match its quantity. */ +function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft { + const target = Math.max(0, Math.floor(quantity) || 0); + const units = [...line.units]; + while (units.length < target) units.push(emptyPartnerUnit()); + units.length = target; + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; +} + +interface Props { + lines: PartnerLineDraft[]; + onLinesChange: (lines: PartnerLineDraft[]) => void; + cargoDescription: string; + onCargoDescriptionChange: (value: string) => void; + /** Whether per-container hazardous / refrigerated ticks apply. */ + showHazardous: boolean; + showReefer: boolean; + /** Surface field errors only after the operator tried to continue. */ + showErrors: boolean; + error?: string; +} + +export function ConsolidationPartnerPanel({ + lines, + onLinesChange, + cargoDescription, + onCargoDescriptionChange, + showHazardous, + showReefer, + showErrors, + error, +}: Props) { + const patchLine = (index: number, patch: Partial) => { + onLinesChange( + lines.map((line, i) => (i === index ? { ...line, ...patch } : line)), + ); + }; + + const patchUnit = ( + lineIndex: number, + unitIndex: number, + patch: Partial, + ) => { + onLinesChange( + lines.map((line, i) => { + if (i !== lineIndex) return line; + const units = line.units.map((unit, u) => + u === unitIndex ? { ...unit, ...patch } : unit, + ); + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; + }), + ); + }; + + return ( + + {error && showErrors ? ( + + {error} + + ) : null} + + {lines.map((line, lineIdx) => ( + + + {line.containerSize} containers + + + patchLine(lineIdx, { quantity: e.currentTarget.value })} + // Sync off the typed value, not the captured `line` — that snapshot + // still holds the pre-edit quantity and would write it back. + onBlur={(e) => { + const typed = e.currentTarget.value; + patchLine(lineIdx, { + ...syncUnits({ ...line, quantity: typed }, Number(typed || 0)), + quantity: typed, + }); + }} + mb={12} + /> + + {line.units.map((unit, unitIdx) => ( + + + Container {unitIdx + 1} + + + + patchUnit(lineIdx, unitIdx, { + containerNumber: e.currentTarget.value.toUpperCase(), + }) + } + /> + + patchUnit(lineIdx, unitIdx, { + sealNumber: e.currentTarget.value, + }) + } + /> + 0) + ? "Required." + : undefined + } + onChange={(e) => + patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value }) + } + /> + + {showHazardous || showReefer ? ( + + {showHazardous ? ( + + patchUnit(lineIdx, unitIdx, { + isHazardous: e.currentTarget.checked, + }) + } + /> + ) : null} + {showReefer ? ( + + patchUnit(lineIdx, unitIdx, { + isReefer: e.currentTarget.checked, + }) + } + /> + ) : null} + + ) : null} + + ))} + + ))} + + onCargoDescriptionChange(e.currentTarget.value)} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx new file mode 100644 index 000000000..c20c08994 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx @@ -0,0 +1,137 @@ +import { + Alert, + Badge, + Box, + Button, + Center, + Group, + Loader, + Modal, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { AlertCircle, Link2 } from "lucide-react"; + +import type { ConsolidationCandidate } from "@/services/contracts.service"; + +/** + * Picker for the booking that shares this booking's wagon. The server has + * already narrowed the list to bookings that can legally pair — same route and + * direction, customs clearing, an odd 20ft count of their own and not already + * linked to someone else — so every row here is a valid choice. + */ +interface Props { + opened: boolean; + onClose: () => void; + candidates: ConsolidationCandidate[]; + isLoading: boolean; + isError: boolean; + onSelect: (candidate: ConsolidationCandidate) => void; +} + +export function ConsolidationPartnerPicker({ + opened, + onClose, + candidates, + isLoading, + isError, + onSelect, +}: Props) { + return ( + + + + + + + Pick the parent booking + + + Customs bookings on the same route that also carry an odd number of + 20ft containers. + + + + } + > + {isLoading ? ( +
+ +
+ ) : isError ? ( + } + > + Could not load the candidate bookings. Close this and try again. + + ) : candidates.length === 0 ? ( + } + title="No booking available to share this wagon" + > + + No other customs booking on this route currently carries an odd + number of 20ft containers. Either wait for one, or switch the + shared-wagon option off and book an even number of 20ft containers. + + + ) : ( + + {candidates.map((candidate) => ( + + + + + + {candidate.reference} + + + {candidate.status.replaceAll("_", " ")} + + + + {candidate.companyName ?? "—"} + {candidate.tradeDirection + ? ` · ${candidate.tradeDirection}` + : ""} + {" · "} + {candidate.hasCargo + ? `${candidate.ft20Quantity} × 20ft` + : "cargo not entered yet"} + + + + + + ))} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d3165f696..1415ad37b 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -191,6 +191,8 @@ 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`, STAFF_REQUEST_CHANGES: (id: string) => `/bookings/${id}/staff/request-changes`, STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`, @@ -317,6 +319,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/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/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 62c26276a..2444899dd 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, @@ -79,14 +80,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 +386,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" ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 8c2a3a912..ebdba3e11 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -15,6 +15,7 @@ import { CheckCircle2, Clock, LayoutList, + Link2, Package, Plus, RefreshCw, @@ -200,10 +201,28 @@ export default function BookingRequestsPage() { // Search is applied server-side (via the `search` filter param) — no // client-side filtering here. - const rows = useMemo( - () => (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; @@ -321,6 +340,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/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index dead9c520..9fa24924d 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -323,6 +323,26 @@ export const bookingsService = { cancel: (id: string, reason: string) => postBooking(B.CANCEL(id), { reason }), + /** + * 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 01dbadac7..f06f2fb61 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -71,6 +71,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; @@ -674,6 +700,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/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