diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts index b2937e98d..5e3987de5 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -70,4 +70,75 @@ describe('BookingsRepository', () => { expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS')); }); + + it('findManualConsolidationCandidates offers an odd-20ft partner booked on another day', async () => { + const qb = mockQueryBuilder(); + // Same route/direction, odd 20ft count, but sitting on a different + // scheduled_date than the booking being completed. GL completes both halves + // onto the date chosen on the form, so this is still a legal partner. + qb.getMany.mockResolvedValue([ + { + id: 'partner', + reference: 'BK-2026-000303', + scheduledDate: new Date('2026-09-02T08:00:00.000Z'), + bookingContainers: [{ quantity: 1, containerType: { sizeFt: 20 } }], + }, + ]); + repository.createQueryBuilder.mockReturnValue(qb as never); + + const result = await bookingsRepository.findManualConsolidationCandidates({ + id: 'own', + originYardId: 'yard-a', + destinationYardId: 'yard-b', + tradeDirection: 'IMPORT', + scheduledDate: new Date('2026-09-01T08:00:00.000Z'), + } as Booking); + + expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-000303']); + expect(result[0].ft20Quantity).toBe(1); + // The booking day must not narrow this list at all. + const dateFilters = qb.andWhere.mock.calls.filter(([clause]) => + String(clause).includes('scheduled_date'), + ); + expect(dateFilters).toHaveLength(0); + }); + + it('findManualConsolidationCandidates falls back to the requested lines before cargo is persisted', async () => { + const qb = mockQueryBuilder(); + // The ordinary state of a CLEARANCE_READY customs booking: container lines + // are written by completion, so there are none yet and the accepted booking + // request is the only statement of what it will carry. + qb.getMany.mockResolvedValue([ + { id: 'b-odd', reference: 'BK-2026-001116', bookingContainers: [] }, + { id: 'b-even', reference: 'BK-EVEN', bookingContainers: [] }, + // No request at all — count unknown, so not offerable. + { id: 'b-unknown', reference: 'BK-UNKNOWN', bookingContainers: [] }, + ]); + repository.createQueryBuilder.mockReturnValue(qb as never); + dataSource.getRepository.mockReturnValue({ + createQueryBuilder: () => ({ + where: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([ + { + createdBookingId: 'b-odd', + requestedLines: { containers: [{ containerSize: '20ft', quantity: 1 }] }, + }, + { + createdBookingId: 'b-even', + requestedLines: { containers: [{ containerSize: '20ft', quantity: 2 }] }, + }, + ]), + }), + }); + + const result = await bookingsRepository.findManualConsolidationCandidates({ + id: 'own', + originYardId: 'yard-a', + destinationYardId: 'yard-b', + tradeDirection: 'EXPORT', + } as Booking); + + expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-001116']); + expect(result[0].ft20Quantity).toBe(1); + }); }); 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 21428c77d..0947dc3ae 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -20,6 +20,7 @@ import { Contract } from '../contracts/entities/contract.entity'; import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; +import { BookingRequest } from '../contracts/entities/booking-request.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { CARGO_TYPE_SUBTREE_SQL, @@ -336,19 +337,35 @@ export class BookingsRepository extends BaseRepository { * {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact * quantity complement — this lists CANDIDATES for a human to choose from, but * every row must still be a legal pick: another customs booking on the same - * route/direction, riding the same booking day, that is itself carrying an odd - * 20ft count. Two odd counts always sum to even, so any pick fills the shared - * wagon. + * route/direction that is itself carrying an odd 20ft count. Two odd counts + * always sum to even, so any pick fills the shared wagon. * - * A booking whose cargo is not entered yet is NOT a candidate: with no - * container lines its 20ft count is unknown, so pairing with it cannot be - * shown to fill the wagon. Same rule as - * {@link findRebookConsolidationCandidates}. + * Deliberately NOT filtered on booking day, unlike + * {@link findComplementaryConsolidationPartner} and + * {@link findRebookConsolidationCandidates}. Those pair bookings that keep the + * dates they already hold, so a mismatched day means two different trains. Here + * both halves are completed together by GL in one shot and + * `completeConsolidatedPair` writes the SAME operator-chosen scheduled_date and + * train to each — see CompleteConsolidatedPairDto — so a partner's stored date + * is about to be overwritten and says nothing about whether it can share the + * wagon. Filtering on it only hid legal partners whose customs clearance + * happened to finish on another day. + * + * The 20ft count comes from the booking's persisted container lines when it + * has them, and otherwise from the accepted booking request that created it. + * That fallback is the normal case here, not an edge case: on a customs + * contract the container lines are written BY completion, so a booking still + * sitting in CLEARANCE_READY — exactly what this list is for — has none yet, + * and its requested quantities are the only statement of what it will carry. + * Reading only the persisted lines left the picker permanently empty. + * + * A booking with neither source is still NOT a candidate: its 20ft count is + * unknown, so pairing with it cannot be shown to fill the wagon. */ async findManualConsolidationCandidates( booking: Booking, limit = 50, - ): Promise { + ): Promise> { const qb = this.repository .createQueryBuilder('b') .leftJoinAndSelect('b.bookingContainers', 'bc') @@ -381,30 +398,65 @@ export class BookingsRepository extends BaseRepository { ], }); - // Same EAT booking day — the pair shares one physical wagon, so it must - // board one train. Applied only when this booking has a date of its own; - // without one there is no day to match against and route/direction stand - // alone, mirroring findComplementaryConsolidationPartner. - if (booking.scheduledDate) { - qb.andWhere( - `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, - { bookingDate: booking.scheduledDate }, - ); - } - const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany(); - // Odd-20ft test in memory. A booking with no container lines has an unknown - // 20ft count, so it cannot be shown to complete the wagon and is not - // offered. - return rows.filter((row) => { + // Requested 20ft quantities for the rows that carry no persisted cargo yet, + // keyed by booking id. One query for the whole page rather than per row. + const pendingIds = rows + .filter((row) => (row.bookingContainers ?? []).length === 0) + .map((row) => row.id); + const requested = await this.findRequested20ftByBooking(pendingIds); + + // Odd-20ft test in memory: two 20ft to a wagon, so odd + odd = whole wagons. + // The resolved count rides along so callers render the same number this + // decision was made on rather than re-deriving it from the empty lines. + const candidates: Array<{ booking: Booking; ft20Quantity: number }> = []; + for (const row of rows) { const lines = row.bookingContainers ?? []; - if (lines.length === 0) return false; - const ft20 = lines - .filter((line) => Number(line.containerType?.sizeFt) === 20) + const ft20 = + lines.length > 0 + ? lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0) + : requested.get(row.id); + // Neither persisted nor requested cargo — the count is unknown, so this + // booking cannot be shown to fill the wagon. + if (ft20 === undefined) continue; + if (ft20 % 2 !== 1) continue; + candidates.push({ booking: row, ft20Quantity: ft20 }); + } + return candidates; + } + + /** + * 20ft quantity each of `bookingIds` was requested with, from the accepted + * booking request that created it. Used to judge bookings whose container + * lines are not written yet — on a customs contract that is every booking + * before completion. Bookings with no request are absent from the map, which + * the caller reads as "unknown", not zero. + */ + private async findRequested20ftByBooking( + bookingIds: string[], + ): Promise> { + const byBooking = new Map(); + if (bookingIds.length === 0) return byBooking; + + const requests = await this.dataSource + .getRepository(BookingRequest) + .createQueryBuilder('r') + .where('r.createdBookingId IN (:...bookingIds)', { bookingIds }) + .getMany(); + + for (const request of requests) { + if (!request.createdBookingId) continue; + // containerSize is free text on the request ('20ft', '20FT'), so parse the + // leading number rather than comparing strings. + const ft20 = (request.requestedLines?.containers ?? []) + .filter((line) => parseInt(String(line.containerSize), 10) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0); - return ft20 % 2 === 1; - }); + byBooking.set(request.createdBookingId, ft20); + } + return byBooking; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index b601e1c3e..4fe111468 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -165,52 +165,34 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { ).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 } }, - ], - }, - // Cargo not entered yet — its 20ft count is unknown, so it cannot be - // shown to fill the wagon and is not offered. - { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, - ]; - + // Which bookings qualify is the repository's decision (and its own spec's); + // what matters here is that the odd 20ft count it resolved survives into the + // response. A booking awaiting completion has no container lines of its own, + // so re-deriving the count from bookingContainers would report 0 and the + // picker would show every candidate as empty. + it('reports the 20ft count the repository resolved, not the persisted lines', async () => { 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 false; - const ft20 = lines - .filter((l) => Number(l.containerType?.sizeFt) === 20) - .reduce((sum, l) => sum + Number(l.quantity || 0), 0); - return ft20 % 2 === 1; - }), - ), + findManualConsolidationCandidates: jest.fn().mockResolvedValue([ + { + // Cargo not persisted yet — the count came from its booking request. + booking: { + id: 'odd', + reference: 'BK-2026-001116', + bookingContainers: [], + }, + ft20Quantity: 1, + }, + ]), }, }); const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); - expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']); - expect(candidates[0].ft20Quantity).toBe(3); + expect(candidates.map((c) => c.reference)).toEqual(['BK-2026-001116']); + expect(candidates[0].ft20Quantity).toBe(1); expect(candidates[0].hasCargo).toBe(true); }); }); 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 88a2175b9..b8205fd33 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 @@ -660,24 +660,25 @@ export class ContractBookingService { 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, - }; - }); + // ft20Quantity comes back from the repository already resolved — persisted + // container lines when the booking has them, otherwise the quantities its + // booking request was accepted with. Recomputing it here from + // bookingContainers would report 0 for every not-yet-completed booking. + return rows.map(({ booking: row, ft20Quantity }) => ({ + 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, + // Cargo is known — from either source — since a candidate with an unknown + // count is never offered. + hasCargo: true, + })); } /** 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 0d5a7046a..4925edb7b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -2007,7 +2007,7 @@ export default function GlCreateBookingForm() { shared wagon — each booking is still priced and invoiced separately. - + /> */} {consolidateOdd ? (