Skip to content
Open
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
23 changes: 23 additions & 0 deletions rtw/booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,27 @@ def _segment_scripts(self, itinerary: Itinerary) -> list[SegmentScript]:
f"Flight {flight_num}: check operating carrier is IB."
)

# --- Through-flight via-stop annotation ---
via_info = ""
if seg.has_via:
via_str = ", ".join(seg.via_airports)
via_info = f"\n Through-flight via: {via_str} (book as single segment)"
warnings.append(
f"Through-flight {route} via {via_str}: must be booked as a "
f"single segment. Splitting into separate segments requires "
f"reissue and may incur fees."
)

# --- CX hub-connection warning ---
if seg.carrier == "CX":
from_apt = seg.from_airport
to_apt = seg.to_airport
if from_apt != "HKG" and to_apt != "HKG":
warnings.append(
f"CX {from_apt}-{to_apt}: D-class may be married through HKG. "
f"Check standalone availability via ExpertFlyer."
)

# --- Build phone instruction ---
date_str = seg.date.strftime("%d %b %Y") if seg.date else "date TBD"
flight_info = f" flight {seg.flight}" if seg.flight else ""
Expand All @@ -214,6 +235,8 @@ def _segment_scripts(self, itinerary: Itinerary) -> list[SegmentScript]:
f"Segment {i + 1}: {seg.carrier}{flight_info} — {route} — {date_str}\n"
f" Booking class: {booking_class}"
)
if via_info:
instruction += via_info
if fj_atr_note:
instruction += fj_atr_note
if seg.notes:
Expand Down
67 changes: 67 additions & 0 deletions rtw/data/through_flights.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Known oneworld through-flights with cross-continent impact
# Through-flight = single flight number with intermediate stop(s)
# Counts as ONE segment per Rule 3015, but ALL stop-continents count for pricing (§16)
#
# Fields:
# carrier: 2-letter IATA code
# flights: list of flight numbers
# from/to: origin/destination airport codes
# via: list of intermediate stop airports
# continents_added: continents gained from via stops (not from from/to)
# notes: optional context
#
# Only includes through-flights where via stop adds a DIFFERENT continent
# than the origin or destination (i.e., cross-continent impact).
# Through-flights where via stop is in the same continent as from/to
# (e.g., QF5/6 SYD-PER-FCO where PER=SWP same as SYD) are listed
# separately under "same_continent_via" for reference.

cross_continent:
- carrier: QF
flights: ["QF1", "QF2"]
from: SYD
to: LHR
via: [SIN]
continents_added: [Asia]
notes: "Flagship A380 service. SIN stop adds Asia to SWP+EU_ME route."

- carrier: BA
flights: ["BA15", "BA16"]
from: LHR
to: SYD
via: [SIN]
continents_added: [Asia]
notes: "BA's Australia service. SIN stop adds Asia to EU_ME+SWP route."

- carrier: QR
flights: ["QR920", "QR921"]
from: DOH
to: ADL
via: [SIN]
continents_added: [Asia]
notes: "Qatar to Adelaide. SIN stop adds Asia to EU_ME+SWP route."

- carrier: QR
flights: ["QR908", "QR909"]
from: DOH
to: MEL
via: [SIN]
continents_added: [Asia]
notes: "Qatar to Melbourne. SIN stop adds Asia to EU_ME+SWP route."

same_continent_via:
- carrier: QF
flights: ["QF5", "QF6"]
from: SYD
to: FCO
via: [PER]
continents_added: []
notes: "PER is SWP (same as SYD). No cross-continent impact."

- carrier: QF
flights: ["QF3", "QF4"]
from: SYD
to: JFK
via: [AKL]
continents_added: []
notes: "AKL is SWP (same as SYD). No cross-continent impact."
103 changes: 103 additions & 0 deletions rtw/rules/married.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Married segment pattern detection. Based on community-reported patterns."""

from rtw.rules.base import register_rule
from rtw.models import RuleResult, Severity


@register_rule
class MarriedSegmentRule:
"""Detect married segment risks from known airline patterns."""

rule_id = "married_segment"
rule_name = "Married Segment Detection"
rule_reference = "Community knowledge (FlyerTalk)"

# Carriers with known hub-connection married segment patterns
_HUB_CARRIERS = {
"CX": ("HKG", "Cathay Pacific often requires HKG stopover for D-class availability"),
}

def check(self, itinerary, context) -> list[RuleResult]:
results = []

# Check 1: CX hub-connection patterns
for i, seg in enumerate(itinerary.segments):
if not seg.is_flown or not seg.carrier:
continue
if seg.carrier in self._HUB_CARRIERS:
hub, reason = self._HUB_CARRIERS[seg.carrier]
from_apt = seg.from_airport
to_apt = seg.to_airport
# If neither endpoint is the hub, D-class may be married through hub
if from_apt != hub and to_apt != hub:
results.append(
RuleResult(
rule_id=self.rule_id,
rule_name=self.rule_name,
rule_reference=self.rule_reference,
passed=False,
severity=Severity.INFO,
message=(
f"Seg {i+1} {from_apt}-{to_apt} on {seg.carrier}: "
f"{reason}. D-class may only be available "
f"on connecting itineraries through {hub}."
),
fix_suggestion=(
f"Check if adding a {hub} stopover improves D-class availability, "
f"or verify standalone availability via ExpertFlyer."
),
segments_involved=[i],
)
)

# Check 2: Through-flight split risk
# If a segment has a via stop, and that via city appears as a
# stopover destination elsewhere, splitting could trigger reissue fees.
via_cities = {} # via_airport -> segment_index
stopover_cities = set()

for i, seg in enumerate(itinerary.segments):
if seg.has_via:
for via_apt in seg.via_airports:
via_cities[via_apt] = i
if seg.is_stopover:
stopover_cities.add(seg.to_airport)

for via_apt, seg_idx in via_cities.items():
if via_apt in stopover_cities:
seg = itinerary.segments[seg_idx]
results.append(
RuleResult(
rule_id=self.rule_id,
rule_name=self.rule_name,
rule_reference="Qantas AgencyConnect",
passed=False,
severity=Severity.INFO,
message=(
f"Seg {seg_idx+1} {seg.from_airport}-{seg.to_airport} "
f"is a through-flight via {via_apt}, but {via_apt} also "
f"appears as a stopover. Splitting the through-flight "
f"into separate segments may require reissue + fee."
),
fix_suggestion=(
f"Keep {seg.from_airport}-{seg.to_airport} as a single "
f"through-flight segment. If you need a stopover in "
f"{via_apt}, book separate segments instead."
),
segments_involved=[seg_idx],
)
)

if not results:
results.append(
RuleResult(
rule_id=self.rule_id,
rule_name=self.rule_name,
rule_reference=self.rule_reference,
passed=True,
severity=Severity.INFO,
message="No married segment risks detected.",
)
)

return results
47 changes: 47 additions & 0 deletions rtw/through_flights.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Through-flight reference data loader.

Known oneworld through-flights where intermediate stops add
cross-continent impact for pricing (Rule 3015 §16).
"""

from pathlib import Path
from typing import Optional

import yaml

_DATA_PATH = Path(__file__).parent / "data" / "through_flights.yaml"
_CACHE: Optional[dict] = None


def load_through_flights() -> dict:
"""Load through-flight reference data."""
global _CACHE
if _CACHE is None:
with open(_DATA_PATH) as f:
_CACHE = yaml.safe_load(f)
return _CACHE


def get_cross_continent_flights() -> list[dict]:
"""Return only through-flights with cross-continent impact."""
data = load_through_flights()
return data.get("cross_continent", [])


def lookup_via_airports(carrier: str, from_apt: str, to_apt: str) -> list[str]:
"""Look up known via airports for a carrier/route pair.

Returns list of via airport codes, or empty list if not a known through-flight.
This is reference-only — users must still add `via:` explicitly to their segments.
"""
for entry in get_cross_continent_flights():
if (entry["carrier"] == carrier
and entry["from"] == from_apt
and entry["to"] == to_apt):
return entry.get("via", [])
# Check reverse direction too
if (entry["carrier"] == carrier
and entry["from"] == to_apt
and entry["to"] == from_apt):
return entry.get("via", [])
return []
36 changes: 36 additions & 0 deletions rtw/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ class ValidationContext:
implicit_continents: list[Continent] = field(default_factory=list)
# Segments that triggered implicit continent detection {continent: [seg_indices]}
implicit_continent_segments: dict[Continent, list[int]] = field(default_factory=dict)
# Via-stop continent visits (through-flight technical stops)
via_continents: list[Continent] = field(default_factory=list)
# Segments that triggered via-continent detection {continent: [(seg_index, via_airport)]}
via_continent_segments: dict[Continent, list[tuple[int, str]]] = field(default_factory=dict)


def build_context(itinerary: Itinerary) -> ValidationContext:
Expand Down Expand Up @@ -132,12 +136,43 @@ def build_context(itinerary: Itinerary) -> ValidationContext:
ctx.intercontinental_arrivals = ic_arrivals
ctx.intercontinental_departures = ic_departures

# Count via-stop continents (through-flight technical stops).
# Via stops affect continent pricing but NOT per-continent segment limits.
_detect_via_continents(ctx, itinerary)

# Detect implicit continent visits (e.g., Asia for EU_ME<->SWP flights)
_detect_implicit_continents(ctx, itinerary)

return ctx


def _detect_via_continents(ctx: ValidationContext, itinerary: Itinerary) -> None:
"""Count continents from through-flight via stops (Rule 3015 §16).

Through-flights with intermediate stops count each stop's continent
as visited for pricing, but do NOT add to per-continent segment limits
(the through-flight is still one segment).
"""
via_segments: dict[Continent, list[tuple[int, str]]] = {}

for i, seg in enumerate(itinerary.segments):
if not seg.has_via:
continue
for via_apt in seg.via_airports:
via_cont = get_continent(via_apt)
if via_cont is None:
continue
if via_cont not in via_segments:
via_segments[via_cont] = []
via_segments[via_cont].append((i, via_apt))
# Add to continents_visited if not already present
if via_cont not in ctx.continents_visited:
ctx.continents_visited.append(via_cont)

ctx.via_continent_segments = via_segments
ctx.via_continents = list(via_segments.keys())


def _detect_implicit_continents(ctx: ValidationContext, itinerary: Itinerary) -> None:
"""Detect implicit continent visits per Rule 3015 §16.

Expand Down Expand Up @@ -193,6 +228,7 @@ def _discover_rules(self) -> None:
import rtw.rules.hemisphere # noqa: F401
import rtw.rules.intercontinental # noqa: F401
import rtw.rules.country # noqa: F401
import rtw.rules.married # noqa: F401

def validate(self, itinerary: Itinerary) -> ValidationReport:
"""Run all rules and return a validation report."""
Expand Down
1 change: 1 addition & 0 deletions rtw/verify/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ class SegmentVerification(BaseModel):
flight_number: Optional[str] = None
target_date: Optional[datetime.date] = None
dclass: Optional[DClassResult] = None
married_segment_note: Optional[str] = None


class VerifyOption(BaseModel):
Expand Down
37 changes: 37 additions & 0 deletions rtw/verify/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
_CACHE_TTL_HOURS = 24
_CACHE_KEY_PREFIX = "dclass"

# Carriers with known married segment patterns (hub-connection)
_MARRIED_CHECK_HUBS = {
"CX": "HKG",
"QR": "DOH",
}


class DClassVerifier:
"""Verify award class availability for itinerary segments.
Expand Down Expand Up @@ -96,6 +102,33 @@ def _store_cache(self, seg: SegmentVerification, result: DClassResult) -> None:
key = self._cache_key(seg)
self.cache.set(key, result.model_dump(mode="json"), ttl_hours=_CACHE_TTL_HOURS)

def _check_married_pattern(
self, seg: SegmentVerification, result: DClassResult
) -> Optional[str]:
"""Detect married segment patterns from ExpertFlyer results.

If a carrier has a known hub and nonstop has 0 seats but
connecting flights have seats > 0, flag as likely married.
"""
carrier = seg.carrier
if not carrier or carrier not in _MARRIED_CHECK_HUBS:
return None
hub = _MARRIED_CHECK_HUBS[carrier]
# Skip if either endpoint IS the hub (not married in that case)
if seg.origin == hub or seg.destination == hub:
return None
# Check: nonstop has 0 seats but connections have seats
nonstop_seats = result.nonstop_seats
connecting_with_seats = [
f for f in result.flights if f.stops > 0 and f.seats > 0
]
if nonstop_seats == 0 and connecting_with_seats:
return (
f"{result.booking_class}-class only via connection "
f"(likely married through {hub})"
)
return None

def verify_option(
self,
option: VerifyOption,
Expand Down Expand Up @@ -174,6 +207,10 @@ def verify_option(
dclass.booking_class = seg_bc
verified.dclass = dclass
self._store_cache(seg, dclass)
# Check for married segment pattern
verified.married_segment_note = self._check_married_pattern(
seg, dclass
)
else:
verified.dclass = DClassResult(
status=DClassStatus.UNKNOWN,
Expand Down
Loading
Loading