mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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<string>();
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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<typeof flight>[]) => 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,
|
||||
|
||||
Reference in New Issue
Block a user