From 0d4ddb208923d7da5ebb86de5ac31ed23358d3f7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 14 Oct 2026 10:45:24 +0200 Subject: [PATCH] fix(booking-import): join flights only on a layover that can be shown (#2653) A missing arrival time skipped the time check, so a return listed first merged into the outbound flight. The layover is now measured from the arrival, or from the departure when there is none, must be forward and under a day, and a leg landing anywhere the chain has been is a return. --- .../nest/booking-import/kitinerary-mapper.ts | 47 +++++-- .../booking-import/kitineraryMapper.test.ts | 120 +++++++++++++++++- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/server/src/nest/booking-import/kitinerary-mapper.ts b/server/src/nest/booking-import/kitinerary-mapper.ts index 5941f9371..cc5c7f3b5 100644 --- a/server/src/nest/booking-import/kitinerary-mapper.ts +++ b/server/src/nest/booking-import/kitinerary-mapper.ts @@ -169,23 +169,44 @@ function mapFlight(r: KiReservation, source: ParsedBookingItem['source']): Parse }; } -/** True when flight `b` is a short layover connection that continues flight `a`. */ -function sameConnection(a: KiReservation, b: KiReservation): boolean { - const fa = a.reservationFor as KiFlight | undefined; +/** The longest layover that still makes one journey, as AirTrail's import joins legs. */ +const MAX_LAYOVER_MS = 24 * 3600 * 1000; + +/** Epoch ms of a kitinerary time, NaN when it is missing or cannot be read. */ +function instantMs(dt: KiDateTimeish): number { + const iso = toIsoString(dt); + return iso ? new Date(iso).getTime() : Number.NaN; +} + +/** + * True when flight `b` continues the `chain` of connecting flights. It has to + * leave from where the chain's last flight landed, must not land anywhere the + * chain has already been (that is a return, however soon it flies), and has to + * leave within a day. The day is counted from the last flight's arrival, or + * from its departure when no arrival is known (an AI import often has none). + * A missing or unreadable departure fails both comparisons: two flights whose + * order cannot be told apart stay two bookings, where a guess had folded a + * return days earlier into the outbound one (#2653). + */ +function sameConnection(chain: KiReservation[], b: KiReservation): boolean { + const fa = chain[chain.length - 1].reservationFor as KiFlight | undefined; const fb = b.reservationFor as KiFlight | undefined; if (!fa || !fb) return false; const arrIata = fa.arrivalAirport?.iataCode?.toUpperCase(); const depIata = fb.departureAirport?.iataCode?.toUpperCase(); if (!arrIata || !depIata || arrIata !== depIata) return false; // must connect at the same airport - const arrIso = toIsoString(fa.arrivalTime); - const depIso = toIsoString(fb.departureTime); - if (arrIso && depIso) { - const gapMs = new Date(depIso).getTime() - new Date(arrIso).getTime(); - // A real layover is forward in time and short — anything longer (e.g. a - // round-trip return days later) stays a separate booking. - if (gapMs < 0 || gapMs > 24 * 3600 * 1000) return false; - } - return true; + const visited = new Set(); + chain.forEach((leg, i) => { + const f = leg.reservationFor as KiFlight | undefined; + if (i === 0 && f?.departureAirport?.iataCode) visited.add(f.departureAirport.iataCode.toUpperCase()); + if (f?.arrivalAirport?.iataCode) visited.add(f.arrivalAirport.iataCode.toUpperCase()); + }); + const nextArr = fb.arrivalAirport?.iataCode?.toUpperCase(); + if (nextArr && visited.has(nextArr)) return false; + const arrived = instantMs(fa.arrivalTime); + const from = Number.isNaN(arrived) ? instantMs(fa.departureTime) : arrived; + const gapMs = instantMs(fb.departureTime) - from; + return gapMs >= 0 && gapMs <= MAX_LAYOVER_MS; } /** Collapse several connecting flight legs (same PNR) into one multi-leg booking. */ @@ -657,7 +678,7 @@ export function mapReservations( kiItems[i + 1]['@type'] === 'FlightReservation' && pnr != null && (kiItems[i + 1].reservationNumber ?? null) === pnr && - sameConnection(group[group.length - 1], kiItems[i + 1]) + sameConnection(group, kiItems[i + 1]) ) { group.push(kiItems[++i]); } diff --git a/server/tests/unit/nest/booking-import/kitineraryMapper.test.ts b/server/tests/unit/nest/booking-import/kitineraryMapper.test.ts index 8ce7faf40..26c514ae9 100644 --- a/server/tests/unit/nest/booking-import/kitineraryMapper.test.ts +++ b/server/tests/unit/nest/booking-import/kitineraryMapper.test.ts @@ -1,4 +1,5 @@ import { mapReservations } from '../../../../src/nest/booking-import/kitinerary-mapper'; +import type { KiReservation } from '../../../../src/nest/booking-import/kitinerary.types'; import { describe, it, expect } from 'vitest'; @@ -8,14 +9,21 @@ const airport = (iata: string, lat: number, lng: number) => ({ geo: { latitude: lat, longitude: lng }, }); -const flight = (pnr: string, dep: any, arr: any, depTime: string, arrTime: string, flightNumber: string) => ({ +const flight = ( + pnr: string, + dep: any, + arr: any, + depTime: string, + arrTime: string | undefined, + flightNumber: string, +) => ({ '@type': 'FlightReservation', reservationNumber: pnr, reservationFor: { departureAirport: dep, arrivalAirport: arr, departureTime: depTime, - arrivalTime: arrTime, + ...(arrTime === undefined ? {} : { arrivalTime: arrTime }), airline: { name: 'Lufthansa', iataCode: 'LH' }, flightNumber, }, @@ -24,6 +32,16 @@ const flight = (pnr: string, dep: any, arr: any, depTime: string, arrTime: strin const FRA = airport('FRA', 50.04, 8.57); const BER = airport('BER', 52.36, 13.5); const HND = airport('HND', 35.55, 139.78); +const ICN = airport('ICN', 37.46, 126.44); +const SFO = airport('SFO', 37.62, -122.38); +const JFK = airport('JFK', 40.64, -73.78); +const BOS = airport('BOS', 42.36, -71.01); +const HAM = airport('HAM', 53.63, 9.99); +const ZRH = airport('ZRH', 47.46, 8.55); + +/** The synthetic legs as the mapper's input type. */ +const legs = (...nodes: ReturnType[]) => nodes as unknown as KiReservation[]; +const meta = (item: { metadata?: unknown }) => (item.metadata ?? {}) as { flight_number?: string; legs?: unknown[] }; describe('kitinerary mapper — multi-leg flight grouping', () => { it('groups two connecting same-PNR legs into one multi-leg booking', () => { @@ -65,6 +83,104 @@ describe('kitinerary mapper — multi-leg flight grouping', () => { expect((items[1].metadata as any).legs).toBeUndefined(); }); + it('keeps a return listed first apart when the arrival time is missing (#2653)', () => { + // The second leg departs three days BEFORE the first: no connection, however + // well the airports line up. + const { items } = mapReservations( + legs( + flight('DEMO77', SFO, JFK, '2031-02-08T10:00:00', undefined, 'EX101'), + flight('DEMO77', JFK, SFO, '2031-02-05T08:00:00', undefined, 'EX100'), + ), + 'synthetic.txt', + ); + + expect(items).toHaveLength(2); + expect(items.map((i) => meta(i).flight_number)).toEqual(['EX101', 'EX100']); + expect(items.every((i) => meta(i).legs === undefined)).toBe(true); + }); + + it('does not take a missing arrival as a layover of three weeks', () => { + const { items } = mapReservations( + legs( + flight('GAP1', SFO, JFK, '2031-02-05T08:00:00', undefined, 'EX1'), + flight('GAP1', JFK, BOS, '2031-02-26T08:00:00', '2031-02-26T09:30:00', 'EX2'), + ), + 'test.json', + ); + + expect(items).toHaveLength(2); + }); + + it('still joins a connection the AI import gave no arrival times', () => { + // The prompt tells the model to leave an arrival out rather than guess it. + const { items } = mapReservations( + legs( + flight('AI1', FRA, BER, '2026-06-11T08:00:00', undefined, 'LH 40'), + flight('AI1', BER, HND, '2026-06-11T11:00:00', undefined, 'LH 41'), + ), + 'test.json', + ); + + expect(items).toHaveLength(1); + expect(meta(items[0]).legs).toHaveLength(2); + }); + + it('treats an arrival time it cannot read like a missing one', () => { + const { items } = mapReservations( + legs( + flight('TBD1', FRA, BER, '2026-06-11T08:00:00', 'TBD', 'LH 20'), + flight('TBD1', BER, HND, '2026-06-11T11:00:00', '2026-06-12T06:00:00', 'LH 21'), + flight('TBD2', FRA, BER, '2026-06-11T08:00:00', 'TBD', 'LH 22'), + flight('TBD2', BER, HND, '2026-06-14T11:00:00', '2026-06-15T06:00:00', 'LH 23'), + ), + 'test.json', + ); + + expect(items.map((i) => meta(i).legs?.length ?? 1)).toEqual([2, 1, 1]); + }); + + it('keeps an out-and-back on the same day as two flights', () => { + const { items } = mapReservations( + legs( + flight('DAY1', FRA, BER, '2026-06-11T08:00:00', '2026-06-11T09:00:00', 'LH 10'), + flight('DAY1', BER, FRA, '2026-06-11T18:00:00', '2026-06-11T19:00:00', 'LH 11'), + ), + 'test.json', + ); + + expect(items).toHaveLength(2); + }); + + it('ends a chain at a leg that lands where the chain has already been', () => { + // Out via FRA and back via FRA on one day, one booking reference. + const { items } = mapReservations( + legs( + flight('TWO', HAM, FRA, '2026-06-11T07:00:00', '2026-06-11T08:00:00', 'LH 50'), + flight('TWO', FRA, ZRH, '2026-06-11T09:00:00', '2026-06-11T10:00:00', 'LH 51'), + flight('TWO', ZRH, FRA, '2026-06-11T17:00:00', '2026-06-11T18:00:00', 'LH 52'), + flight('TWO', FRA, HAM, '2026-06-11T19:00:00', '2026-06-11T20:00:00', 'LH 53'), + ), + 'test.json', + ); + + expect(items.map((i) => meta(i).legs?.length)).toEqual([2, 2]); + }); + + it('still joins a chain of three short connections into one booking', () => { + const { items } = mapReservations( + legs( + flight('CH3', FRA, BER, '2026-06-11T08:00:00', '2026-06-11T09:00:00', 'LH 30'), + flight('CH3', BER, HND, '2026-06-11T11:00:00', '2026-06-12T06:00:00', 'LH 31'), + flight('CH3', HND, ICN, '2026-06-12T09:00:00', '2026-06-12T11:30:00', 'LH 32'), + ), + 'test.json', + ); + + expect(items).toHaveLength(1); + expect(meta(items[0]).legs).toHaveLength(3); + expect((items[0].endpoints ?? []).map((e) => e.role)).toEqual(['from', 'stop', 'stop', 'to']); + }); + it('leaves a single flight unchanged (two endpoints, no legs array)', () => { const { items } = mapReservations( [flight('S1', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 1')] as any,