Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 91 additions & 21 deletions src/flight_cli/pp/match.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand All @@ -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:
Expand All @@ -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.

Expand All @@ -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."""
Expand Down Expand Up @@ -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.
Expand All @@ -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
165 changes: 165 additions & 0 deletions tests/pp/test_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 ─────────────────────────────


Expand Down
Loading