diff --git a/src/flight_cli/pp/match.py b/src/flight_cli/pp/match.py index ecbcf22..cf10b2a 100644 --- a/src/flight_cli/pp/match.py +++ b/src/flight_cli/pp/match.py @@ -1,8 +1,16 @@ """Join Matrix cash itineraries to PointsPath award flights. -Match key: (normalized first-segment flight number, ISO departure date). +Primary key: (normalized first-segment flight number, ISO departure date). Same key can appear at most once per side per day, so a dict-lookup is enough. +Codeshare-fallback key: (origin, destination, departure datetime to minute). +Matrix returns codeshares under the *marketing* flight number (e.g. AA6939 +JFK→LHR), while PointsPath returns the same physical aircraft under the +*operating* flight number (e.g. BA174 — surfaced inside the American PP +query, because PP attributes codeshares to the operator). Flight-number +keys can't bridge that, but route+time can: both sources read the same +airline-published schedule, so origin+dest+minute is a near-tight identity. + Outputs MatchedFare records, one per cash itinerary, with optional award data attached. Caller renders. """ @@ -23,6 +31,9 @@ ) MatchKey = tuple[str, str] # (FLIGHT_NUMBER_UPPER_NOSPACE, "YYYY-MM-DD") +RouteTimeKey = tuple[str, str, str] # (ORIGIN_UPPER, DEST_UPPER, "YYYY-MM-DDTHH:MM") + +_ISO_MINUTE_LEN = 16 # "YYYY-MM-DDTHH:MM" def _norm_fn(fn: str | None) -> str: @@ -39,6 +50,15 @@ def _iso_date(s: str | None) -> str: return s[:10] +def _iso_minute(s: str | None) -> str: + """Trim a datetime string to YYYY-MM-DDTHH:MM (minute precision). Tolerant + of the space-separator Matrix sometimes returns. '' on missing/short.""" + if not s: + return "" + s = s.replace(" ", "T") + return s[:_ISO_MINUTE_LEN] if len(s) >= _ISO_MINUTE_LEN else "" + + def cash_match_key(it: Itinerary, slice_index: int = 0) -> MatchKey | None: """Build the match key from a Matrix itinerary's slice's first flight. @@ -63,6 +83,32 @@ def award_match_key(of: OutboundFlight) -> MatchKey: return (_norm_fn(of.firstFlightNumber), _iso_date(of.localDepartureDateTime)) +def cash_route_time_key(it: Itinerary, slice_index: int = 0) -> RouteTimeKey | None: + """Codeshare-fallback key: origin, destination, minute-precision departure. + + Doesn't require `slice.flights` to be populated — origin/dest/departure + are enough to anchor the same physical flight on the award side.""" + itn = it.itinerary + if not itn or not itn.slices or slice_index >= len(itn.slices): + return None + s = itn.slices[slice_index] + o = ((s.origin.code if s.origin else None) or "").upper() + d = ((s.destination.code if s.destination else None) or "").upper() + t = _iso_minute(s.departure) + if not (o and d and t): + return None + return (o, d, t) + + +def award_route_time_key(of: OutboundFlight) -> RouteTimeKey | None: + o = (of.origin or "").upper() + d = (of.destination or "").upper() + t = _iso_minute(of.localDepartureDateTime) + if not (o and d and t): + return None + return (o, d, t) + + @dataclass class CabinAward: """One cabin's award price for a single flight.""" @@ -123,7 +169,13 @@ def join( slice_index: int = 0, use_inbound: bool = False, ) -> list[MatchedFare]: - """Outer-join cash itineraries onto award flights by (flight#, date). + """Outer-join cash itineraries onto award flights. + + Match strategy: primary by (flight#, date), then a route+time fallback to + catch codeshares (Matrix's marketing flight# won't equal PP's operating + flight#, but origin+dest+minute identifies the same physical flight). + Hits from both keys are unioned and deduped by OutboundFlight identity, so + non-codeshare flights aren't double-attached. Cash itineraries with no award match keep an empty `awards` list — caller decides whether to render them or filter to inner-join. @@ -137,32 +189,50 @@ def join( """ pricing_idx = _index_pricing(pricing) - # Build award index. One key may surface from multiple airlines (codeshares), - # so we keep a list per key. - award_idx: dict[MatchKey, list[tuple[str, OutboundFlight]]] = {} + # Build both award indexes in one pass over the flights. A single flight + # may appear under both — that's expected; per-itinerary dedup in the + # join loop keeps the output clean. + fn_idx: dict[MatchKey, list[tuple[str, OutboundFlight]]] = {} + rt_idx: dict[RouteTimeKey, list[tuple[str, OutboundFlight]]] = {} for airline, resp in award_by_airline.items(): flights = resp.inboundFlights if use_inbound else resp.outboundFlights for of in flights: - k = award_match_key(of) - if not k[0]: - continue - award_idx.setdefault(k, []).append((airline, of)) + fn_k = award_match_key(of) + if fn_k[0]: + fn_idx.setdefault(fn_k, []).append((airline, of)) + rt_k = award_route_time_key(of) + if rt_k: + rt_idx.setdefault(rt_k, []).append((airline, of)) out: list[MatchedFare] = [] for it in search.solutions: - k = cash_match_key(it, slice_index=slice_index) awards: list[AwardOption] = [] - if k and k in award_idx: - for airline, of in award_idx[k]: - pi = pricing_idx.get(airline) - awards.append( - AwardOption( - airline=airline, - miles_to_cash_ratio=pi.milesToCashRatio if pi else 0.0, - flight=of, - cabins=_cabin_awards(of.perCabinMilesPricing), - funding_banks=[b.bank for b in (pi.bankPointsInfos if pi else [])], - ) + seen_ids: set[int] = set() + + # Collect (airline, OutboundFlight) hits from both keys, then convert + # to AwardOption once per unique flight. + hits: list[tuple[str, OutboundFlight]] = [] + fn_k = cash_match_key(it, slice_index=slice_index) + if fn_k and fn_k in fn_idx: + hits.extend(fn_idx[fn_k]) + rt_k = cash_route_time_key(it, slice_index=slice_index) + if rt_k and rt_k in rt_idx: + hits.extend(rt_idx[rt_k]) + + for airline, of in hits: + if id(of) in seen_ids: + continue + seen_ids.add(id(of)) + pi = pricing_idx.get(airline) + awards.append( + AwardOption( + airline=airline, + miles_to_cash_ratio=pi.milesToCashRatio if pi else 0.0, + flight=of, + cabins=_cabin_awards(of.perCabinMilesPricing), + funding_banks=[b.bank for b in (pi.bankPointsInfos if pi else [])], ) + ) + out.append(MatchedFare(itinerary=it, awards=awards)) return out diff --git a/tests/pp/test_match.py b/tests/pp/test_match.py index f71a124..86c2ba3 100644 --- a/tests/pp/test_match.py +++ b/tests/pp/test_match.py @@ -18,7 +18,9 @@ ) from flight_cli.pp.match import ( award_match_key, + award_route_time_key, cash_match_key, + cash_route_time_key, join, ) from flight_cli.pp.models import ( @@ -117,6 +119,169 @@ def test_award_match_key_normalizes_consistently(): assert award_match_key(of) == ("UA146", "2026-06-09") +# ───────────────────────── route+time key ────────────────────────────────── + + +def test_cash_route_time_key_includes_origin_dest_minute(): + it = _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")) + assert cash_route_time_key(it) == ("JFK", "LHR", "2026-08-15T18:40") + + +def test_cash_route_time_key_uppercases_airports(): + """IATA codes are case-insensitive on the wire; canonicalize so a malformed + 'jfk' on one side still matches a proper 'JFK' on the other.""" + it = _itin(("AA6939", "2026-08-15T18:40:00", "jfk", "lhr")) + assert cash_route_time_key(it) == ("JFK", "LHR", "2026-08-15T18:40") + + +def test_cash_route_time_key_handles_space_separated_iso(): + it = _itin(("AA6939", "2026-08-15 18:40", "JFK", "LHR")) + assert cash_route_time_key(it) == ("JFK", "LHR", "2026-08-15T18:40") + + +def test_cash_route_time_key_out_of_range_slice_returns_none(): + it = _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")) + assert cash_route_time_key(it, slice_index=5) is None + + +def test_cash_route_time_key_works_when_flights_list_is_empty(): + """Route+time doesn't need flight numbers — it should still produce a + key for a slice that has origin/dest/departure but no flights[].""" + from flight_cli.models import ( + Itinerary, + ItineraryDetails, + Slice, + SliceEndpoint, + ) + + it = Itinerary( + itinerary=ItineraryDetails( + slices=[ + Slice( + flights=[], + departure="2026-08-15T18:40:00", + origin=SliceEndpoint(code="JFK"), + destination=SliceEndpoint(code="LHR"), + ) + ], + ), + ) + assert cash_route_time_key(it) == ("JFK", "LHR", "2026-08-15T18:40") + + +def test_award_route_time_key_normalizes_consistently(): + of = _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="LHR") + assert award_route_time_key(of) == ("JFK", "LHR", "2026-08-15T18:40") + + +# ───────────────── codeshare fallback (the work-22az fix) ────────────────── + + +def test_join_codeshare_via_route_time_when_flight_numbers_differ(): + """Matrix returns the marketing flight number; PP returns the operating + flight number for the same physical aircraft. The (flight#, date) key + can't bridge them, but the route+time fallback does. + + Real example from a live probe: AA6939 (AA-marketed) ↔ BA174 (BA-operated). + """ + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), + ] + ) + # PP's American airline-search returns BA-operated codeshares under the + # OPERATING flight number — this is what we observed in the probe. + award_resp = AirlineSearchResponse( + outboundFlights=[ + _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="LHR"), + ] + ) + matches = join(res, {"American": award_resp}, _pricing()) + assert len(matches[0].awards) == 1 + assert matches[0].awards[0].flight.firstFlightNumber == "BA174" + + +def test_join_does_not_double_attach_when_both_keys_match(): + """The non-codeshare case: Matrix and PP both report UA146 at the same + time, so both the flight# key AND the route+time key fire. Dedup must + keep it to one award per OutboundFlight.""" + res = SearchResult( + solutions=[ + _itin(("UA146", "2026-06-09T22:00:00", "JFK", "LHR")), + ] + ) + award_resp = AirlineSearchResponse( + outboundFlights=[ + _award("UA146", "2026-06-09T22:00:00", origin="JFK", dest="LHR"), + ] + ) + matches = join(res, {"United": award_resp}, _pricing()) + assert len(matches[0].awards) == 1 + + +def test_join_route_time_fallback_does_not_match_different_route(): + """Sanity: a JFK→LHR cash flight at 18:40 must not match a JFK→ORD flight + at 18:40, even though times agree.""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), + ] + ) + award_resp = AirlineSearchResponse( + outboundFlights=[ + _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="ORD"), + ] + ) + matches = join(res, {"American": award_resp}, _pricing()) + assert matches[0].awards == [] + + +def test_join_route_time_fallback_requires_minute_precision(): + """A 5-minute offset must not fall back. (We can broaden to a tolerance + window later if real traffic shows minor schedule-source drift.)""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), + ] + ) + award_resp = AirlineSearchResponse( + outboundFlights=[ + _award("BA174", "2026-08-15T18:45:00", origin="JFK", dest="LHR"), + ] + ) + matches = join(res, {"American": award_resp}, _pricing()) + assert matches[0].awards == [] + + +def test_join_route_time_unions_with_flight_number_match(): + """If two airlines' PP responses describe overlapping award metal — + one matches by flight number, the other matches by route+time — both + should attach to the same cash itinerary.""" + res = SearchResult( + solutions=[ + _itin(("AA6939", "2026-08-15T18:40:00", "JFK", "LHR")), + ] + ) + award_by_airline = { + # Same metal under the OPERATING number (matches via route+time): + "American": AirlineSearchResponse( + outboundFlights=[ + _award("BA174", "2026-08-15T18:40:00", origin="JFK", dest="LHR"), + ] + ), + # Hypothetical second airline that happens to report the cash + # marketing number directly (matches via primary key): + "VirginAtlantic": AirlineSearchResponse( + outboundFlights=[ + _award("AA6939", "2026-08-15T18:40:00", origin="JFK", dest="LHR"), + ] + ), + } + matches = join(res, award_by_airline, _pricing()) + airlines = {ao.airline for ao in matches[0].awards} + assert airlines == {"American", "VirginAtlantic"} + + # ────────────────────────────── join semantics ─────────────────────────────