Phase 3: Sabre 발권 API 심층 분석

1. API 엔드포인트 개요

1.1 발권 관련 엔드포인트

엔드포인트메서드기능위치
/internals/SABRE/ticketing/readyPOST발권 준비 및 검증 (Deprecated)SabreTicketingController.kt
/internals/SABRE/ticketingPOST실제 발권 실행SabreTicketingController.kt

2. 발권 준비 (Ready) - @Deprecated

2.1 발권 준비 플로우

flowchart TD
    A[발권 준비 요청] --> B[Session Token 획득<br/>getSessionToken]
    B --> C[PNR 정보 조회<br/>getBooking]
    C --> D[발권 가능 상태 검증<br/>validateBookingConditionForTicketing]

    D --> E{PriceQuote<br/>생성일 확인}
    E -->|당일| F[스케줄 정보만 반환<br/>null to schedules]
    E -->|당일 아님| G[기존 PriceQuote 삭제<br/>deletePriceQuote]

    G --> H[Repricing 수행<br/>repricing]
    H --> I[트랜잭션 종료<br/>endTransaction]
    I --> J[PNR 재조회<br/>getBooking]
    J --> K[FareBasis 변경 검증<br/>validateFareBasisChange]
    K --> L[승객 & 스케줄 반환<br/>passengers to schedules]

    F --> M[closeSessionToken]
    L --> M
    M --> N[결과 반환]

     다크/라이트 모드 호환 색상
    style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style D fill:#B3D9FF,stroke:#333,stroke-width:2px,color:#000
    style K fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style O fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style N fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

위치: SabreTicketingService.kt:26-50

2.2 PriceQuote 생성일 기반 Repricing

return if (booking.priceQuoteCreatedAt.toLocalDate() != today()) {
    sabreClient.deletePriceQuote(token)
    sabreClient.repricing(token, booking.passengers)
    sabreClient.endTransaction(token)
    sabreClient.getBooking(token = token, pnr = pnr).also {
        validateFareBasisChange(originBooking = booking, newBooking = it)
    }.let { it.passengers to it.schedules }
} else {
    null to booking.schedules
}

위치: SabreTicketingService.kt:32-40

Repricing 조건

  • 조건: PriceQuote 생성일이 오늘이 아닌 경우
  • 목적: 운임 재계산으로 최신 가격 반영
  • 프로세스:
    1. 기존 PriceQuote 삭제 (DeletePriceQuoteRQ)
    2. 새로운 PriceQuote 생성 (OtaAirPriceRQ)
    3. 트랜잭션 종료로 PNR 저장
    4. PNR 재조회로 새 PriceQuote 검증

2.3 FareBasis 변경 검증

private fun validateFareBasisChange(originBooking: Booking, newBooking: Booking) {
    newBooking.passengers.forEach { newPassenger ->
        val originFareBasis = originBooking.passengers
            .first { it.type == newPassenger.type && it.identificationKey == newPassenger.identificationKey }
            .fare?.fareComponents?.joinToString(",")
        val newFareBasis = newPassenger.fare?.fareComponents?.joinToString(",")
 
        if (originFareBasis != newFareBasis) {
            cancelService.onlyPnrCancel(pnr = originBooking.pnr!!)
            throw StatusInvalidException(
                ErrorMessage.SOLD_OUT,
                "${newPassenger.type.name} fareBasis is changed ($originFareBasis->$newFareBasis)",
            ).capture()
        }
    }
}

위치: SabreTicketingService.kt:199-216

검증 로직

  • 목적: Repricing 후 운임 코드 변경 감지
  • 비교 대상: FareComponent 리스트 (콤마 구분 문자열 비교)
  • 실패 시: PNR 취소 후 SOLD_OUT 예외 발생
  • 대상: 모든 승객 타입 & IdentificationKey 매칭

3. 발권 실행 (Issue)

3.1 전체 발권 플로우

flowchart TD
    A[발권 요청] --> B[Session Token 획득<br/>getSessionToken]
    B --> C[PNR 정보 조회 & 검증<br/>getBooking]
    C --> D{결제 방식<br/>분기}

    D -->|TossPay| E[TossPay 결제 승인<br/>approveTossPay]
    D -->|KeyInCard| F[KeyInCard 결제 승인<br/>approve + pgCardCode]

    E --> G[발권 요청<br/>ticketing]
    F --> G

    G --> H{Uncommitted<br/>Ticket 확인}
    H -->|존재| I[예외 발생<br/>TICKETING_FAILED]
    H -->|없음| J[티켓 조회<br/>retrieveTicket]

    J --> K[Payment 정보 결합<br/>withPayment]
    K --> L[closeSessionToken]
    L --> M[발권 완료 반환]

     다크/라이트 모드 호환 색상
    style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style H fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style I fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style N fill:#FFE5B4,stroke:#333,stroke-width:2px,color:#000
    style O fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style M fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

위치: SabreTicketingService.kt:52-134

3.2 결제 처리 분기

3.2.1 결제 방식 분기 로직

fun approve(
    pnr: String,
    validatingCarrier: String,
    passengerPrices: List<PassengerPrice>,
    paymentInfo: PaymentInfo,
) = when (paymentInfo) {
    is PaymentInfo.TossPay -> {
        sabrePaymentClient.approveTossPay(
            pnr = pnr,
            validatingCarrier = validatingCarrier,
            passengerPrices = passengerPrices,
            tossPay = paymentInfo,
        )
    }
 
    is PaymentInfo.KeyInCard -> {
        sabrePaymentClient.approve(
            validatingCarrier = validatingCarrier,
            passengerPrices = passengerPrices,
            cardInfo = paymentInfo,
            cardCode = sabrePaymentClient.pgCardCode(
                cardNumber = paymentInfo.cardNumber,
                validatingCarrier = validatingCarrier
            ),
        )
    }
}

위치: SabrePaymentService.kt:23-49

3.2.2 TossPay vs KeyInCard 비교

항목TossPayKeyInCard
메서드approveTossPayapprove
카드 코드TossPay에서 제공pgCardCode 조회 필요
PNR 파라미터필요불필요
취소 메서드cancelTossPaycancel
Payment 플래그isTossPayPayment = trueisTossPayPayment = false

3.3 발권 요청 (AirTicketRQ)

3.3.1 발권 API 호출

val passengerTickets = sabreClient.ticketing(
    token = token,
    pnr = pnr,
    validatingCarrier = validatingCarrier,
    passengers = passengers,
    passengerPrices = passengerPrices,
    payment = payment,
    paymentInfo = paymentInfo,
    addEndorsement = "RFND ONLY TO ISSUE AGT".takeIf { validatingCarrier == "PR" },
    timeoutCallback = {
        slackService.sendTicketingTimeout(
            supplier = Supplier.SABRE,
            pnr = pnr,
        )
    }
)

위치: SabreTicketingService.kt:75-90

3.3.2 항공사별 Endorsement 처리

PR 항공사 (Philippine Airlines) 특별 처리

addEndorsement = "RFND ONLY TO ISSUE AGT".takeIf { validatingCarrier == "PR" }

위치: SabreTicketingService.kt:83

  • 조건: validatingCarrier == “PR”
  • Endorsement: “RFND ONLY TO ISSUE AGT”
  • 목적: PR 항공사 환불 정책 명시 (발권 대리점에서만 환불 가능)
  • 전달: AirTicketRQ.of()MiscQualifier.of()Endorsement.of()

4. 항공사별 특별 발권 처리

4.1 MU 항공사 (중국동방항공) - DOB Endorsement

4.1.1 비성인 티켓 분리 로직

private fun individualTickets(
    validatingCarrier: String,
    passengers: List<Passenger>,
    passengerPrices: List<PassengerPrice>,
    payment: Payment,
    paymentInfo: PaymentInfo,
    addEndorsement: String?,
): List<Ticketing> {
    val (adults, childOrInfants) = passengers.partition { it.type == PassengerType.ADULT }
    val adultKeys = adults.mapNotNull { it.identificationKey }.toSet()
    val childOrInfantKeys = childOrInfants.mapNotNull { it.identificationKey }.toSet()
 
    return buildList {
        addAll(
            groupedTickets(
                validatingCarrier = validatingCarrier,
                passengers = adults,
                passengerPrices = passengerPrices.filter { it.identificationKey in adultKeys },
                payment = payment,
                paymentInfo = paymentInfo,
                addEndorsement = addEndorsement,
            )
        )
 
        addAll(
            dobTickets(
                validatingCarrier = validatingCarrier,
                passengers = childOrInfants,
                passengerPrices = passengerPrices.filter { it.identificationKey in childOrInfantKeys },
                payment = payment,
                paymentInfo = paymentInfo,
            )
        )
    }
}

위치: AirTicketRQ.kt:78-112

4.1.2 DOB Endorsement 생성

private fun dobTickets(
    validatingCarrier: String,
    passengers: List<Passenger>,
    passengerPrices: List<PassengerPrice>,
    payment: Payment,
    paymentInfo: PaymentInfo,
): List<Ticketing> {
    return passengerPrices.map { price ->
        val passenger = passengers.first { passenger ->
            passenger.identificationKey == price.identificationKey
        }
 
        val isNetRemit = passenger.fare?.isNetRemit == true
        val (commission, needAdditionalCommission) = convertCommission(
            pqCommission = passenger.fare?.commission,
            overCommission = price.commission
        )
 
        Ticketing(
            flightQualifier = FlightQualifier.of(validatingCarrier = validatingCarrier),
            fopQualifier = FopQualifier.of(
                payment = payment,
                paymentInfo = paymentInfo,
                cashPrice = price.cashPrice
            ),
            pricingQualifier = PricingQualifier.of(passengers = listOf(passenger)),
            miscQualifier = MiscQualifier.of(
                commission = commission.takeIf { needAdditionalCommission },
                addEndorsement = passenger.birthDate?.let {
                    "DOB${
                        passenger.birthDate.format("ddMMMyy", Locale.ENGLISH).uppercase(Locale.getDefault())
                    }"
                },
                approvalNumber = payment.approvalNumber!!,
                cardPrice = price.cardPrice.takeIf { isNetRemit }
            )
        )
    }
}

위치: AirTicketRQ.kt:114-152

4.1.3 DOB 포맷

  • 형식: DOB{ddMMMyy} (예: DOB15JAN20)
  • 대상: Child & Infant (PassengerType.ADULT 제외)
  • 예시: 2020년 1월 15일 생 → DOB15JAN20
  • Locale: Locale.ENGLISH (월 영문 표기)

4.1.4 MU 항공사 발권 플로우

flowchart TD
    A[AirTicketRQ.of 호출] --> B{validatingCarrier<br/>== 'MU' &&<br/>비성인 포함?}

    B -->|Yes| C[individualTickets<br/>성인/비성인 분리]
    B -->|No| D[groupedTickets<br/>통합 발권]

    C --> E[성인: groupedTickets<br/>addEndorsement 포함]
    C --> F[비성인: dobTickets<br/>DOB Endorsement]

    F --> G[각 비성인별<br/>개별 Ticketing 생성]
    G --> H["DOB{ddMMMyy}"<br/>Endorsement 추가]

    E --> I[Ticketing 리스트 병합]
    H --> I
    D --> I
    I --> J[AirTicketRQ 반환]

     다크/라이트 모드 호환 색상
    style B fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style F fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
    style G fill:#B3D9FF,stroke:#333,stroke-width:2px,color:#000
    style H fill:#B3D9FF,stroke:#333,stroke-width:2px,color:#000
    style I fill:#FFE5B4,stroke:#333,stroke-width:2px,color:#000

5.3 MiscQualifier Commission 설정

miscQualifier = MiscQualifier.of(
    commission = commission.takeIf { needAdditionalCommission },
    addEndorsement = addEndorsement,
    approvalNumber = payment.approvalNumber!!,
    cardPrice = firstPassengerPrice.cardPrice.takeIf { isNetRemit }
)

위치: AirTicketRQ.kt:184-189

  • needAdditionalCommission = false: MiscQualifier에 commission 추가 안 함 (PQ 기존 값 유지)
  • needAdditionalCommission = true: MiscQualifier에 commission 추가 (PQ 값 오버라이드)

6. 발권 검증 및 에러 처리

6.1 Uncommitted Ticket 검증

ticketCount = passengerTickets.size
val uncommittedTickets = passengerTickets.filterNot { it.committed }
if (uncommittedTickets.isNotEmpty()) {
    throw StatusInvalidException(
        ErrorMessage.TICKETING_FAILED,
        pnr,
        "uncommitted tickets",
        uncommittedTickets.joinToString()
    ).capture()
}

위치: SabreTicketingService.kt:91-100

Committed 플래그

  • 의미: 티켓이 GDS에 정상 저장되었는지 여부
  • 확인: PassengerTicket.committed 필드
  • 실패 시: 발권 실패 예외 발생 (티켓 리스트 포함)

6.2 발권 API 에러 처리

6.2.1 INCORRECT CARD NUMBER

when {
    any { it.contains("INCORRECT CARD NUMBER") } -> {
        listOf(
            messages.first(),
            "INCORRECT CARD NUMBER",
            validatingCarrier,
            (paymentInfo as? PaymentInfo.KeyInCard)?.cardNumber?.take(8) ?: "",
            payment.cardCode ?: ""
        )
    }
}

위치: SabreClient.kt:528-536

  • 조건: 응답 메시지에 “INCORRECT CARD NUMBER” 포함
  • 로그: 항공사, 카드 앞 8자리, cardCode
  • 원인: 항공사가 인식하지 못하는 카드 코드

6.2.2 VERIFY COMMISSION-2133

any { it.contains("VERIFY COMMISSION-2133") } -> {
    listOf(
        messages.first(),
        "COMMISSION TYPE",
        validatingCarrier,
        "OVER COMMISSION: ${passengerPrices.first().commission?.type}",
        "PQ COMMISSION: ${passengers.first().fare?.commission?.type}",
    )
}

위치: SabreClient.kt:539-547

  • 조건: Commission 불일치 에러
  • 로그: Over Commission vs PQ Commission 타입 비교
  • 원인: 요청한 commission이 PQ의 commission보다 클 때

6.2.3 Timeout 처리

failure = {
    if (it.isTimeout) {
        timeoutCallback()
    }
    throw it.handleSoapFaultException(ErrorMessage.TICKETING_FAILED, pnr)
}

위치: SabreClient.kt:558-563

  • 콜백: slackService.sendTicketingTimeout()
  • 목적: 발권 타임아웃 즉시 알림
  • 후속 처리: 예외 재발생

6.3 발권 후 티켓 조회 (retrieveTicket)

6.3.1 티켓 조회 플로우

flowchart TD
    A[retrieveTicket 호출] --> B[PNR 재조회<br/>getBooking]
    B --> C[승객-티켓 매칭<br/>PassengerFormat.Name.fromCarrier]
    C --> D{매칭 실패<br/>승객 존재?}

    D -->|Yes| E[Slack 알림<br/>sendPartiallyTicketingFail]
    E --> F[예외 발생<br/>PARTIALLY_TICKETING_FAILED]

    D -->|No| G[정상 Booking 반환]

     다크/라이트 모드 호환 색상
    style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style I fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style K fill:#FFE5B4,stroke:#333,stroke-width:2px,color:#000
    style L fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style J fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

closeSessionToken 실패 시

  • Slack 알림: sendIncompleteTicketing (PNR, 실패 사유)
  • 예외 재발생: finally 블록에서 예외 throw
  • 목적: Session 미종료로 인한 리소스 누수 방지

9. Amadeus vs Sabre 발권 비교

항목AmadeusSabre
Ready API실제 사용@Deprecated (API 분리 예정)
Repricing 조건명시적 조건 없음PriceQuote 생성일 != 오늘
결제 분기KE vs 기타 (GPS)TossPay vs KeyInCard
항공사별 특별 처리CZ (성인 티켓 저장)
MU (Endorsement)
PR (Endorsement)
MU (DOB Endorsement)
EndorsementMU만 특별 처리PR, MU 각각 다른 처리
MU 비성인 처리issueWithEndorsementsdobTickets (DOB{ddMMMyy})
PR 항공사없음”RFND ONLY TO ISSUE AGT”
CommissionremoveElements 후 설정convertCommission 우선순위
티켓 그룹핑없음 (개별 발권)chunked(5) 그룹핑
비동기 취소5초 대기5초 대기 (동일)
Session 관리Start → InSeries → EndgetSessionToken → closeSessionToken
closeSessionToken 실패로그만Slack 알림 + 예외 발생

10. 주요 데이터 구조

10.1 AirTicketRQ 구조

@JacksonXmlRootElement(localName = "AirTicketRQ", namespace = "http://services.sabre.com/sp/air/ticket/v1_3")
data class AirTicketRQ(
    @JacksonXmlProperty(isAttribute = true, localName = "version")
    val version: String = "1.3.0",
 
    @JacksonXmlProperty(localName = "DesignatePrinter")
    val designatePrinter: DesignatePrinter,
 
    @JacksonXmlProperty(localName = "Itinerary")
    val itinerary: Itinerary,
 
    @JacksonXmlProperty(localName = "Ticketing")
    @JacksonXmlElementWrapper(useWrapping = false)
    val ticketings: List<Ticketing>,
 
    @JacksonXmlProperty(localName = "PostProcessing")
    val postProcessing: PostProcessing = PostProcessing(),
) : SabreRequest

위치: AirTicketRQ.kt:18-34

10.2 Ticketing 구조

data class Ticketing(
    val flightQualifier: FlightQualifier,     // validatingCarrier
    val fopQualifier: FopQualifier,           // 결제 정보
    val pricingQualifier: PricingQualifier,   // 승객 정보
    val miscQualifier: MiscQualifier,         // Commission, Endorsement, NetRemit
)

10.3 MiscQualifier 상세

data class MiscQualifier(
    val commission: Commission?,              // 커미션 (needAdditional=true일 때만)
    val discount: Discount,                   // 승인번호 (approvalNumber)
    val endorsement: Endorsement?,            // PR: "RFND ONLY...", MU: "DOB..."
    val invoice: Invoice? = Invoice(),        // ETR 영수증
    val netRemit: NetRemit? = null,          // isNetRemit일 때 cardPrice
    val ticket: Ticket? = Ticket(),          // Type: ETR (기본)
    val tourCode: TourCode? = null,          // commission의 tourCode
)

위치: MiscQualifier.kt:7-28


11. 발권 상태 검증 (validateBookingConditionForTicketing)

11.1 검증 항목

fun Booking.validateBookingConditionForTicketing() {
    // 1. 티켓 존재 확인
    if (passengers.any { it.eTicket != null }) {
        throw StatusInvalidException(ErrorMessage.TICKETING_FAILED, pnr, "ticket exists")
    }
 
    // 2. 스케줄 상태 확인
    schedules.forEach { schedule ->
        if (!schedule.confirmed && !schedule.confirming) {
            throw StatusInvalidException(
                ErrorMessage.NOT_OK_SCHEDULE,
                pnr,
                "schedule status: ${schedule.status}"
            )
        }
    }
}

참고: Phase 2 문서 참조 (Booking 검증 로직)


12. 요약 및 핵심 포인트

12.1 발권 프로세스 핵심

  1. Ready (Deprecated): PriceQuote 생성일 기반 Repricing
  2. Issue: Session Token → 결제 → 발권 → 티켓 조회
  3. 결제 분기: TossPay vs KeyInCard (pgCardCode 조회)
  4. 항공사별 처리:
    • PR: “RFND ONLY TO ISSUE AGT” Endorsement
    • MU: Child/Infant DOB Endorsement (DOB{ddMMMyy})
  5. Commission: Over > PQ > 기본 (0.0% GROSS)
  6. 티켓 그룹핑: 타입+가격별 그룹 → 5명씩 청킹
  7. 에러 처리:
    • Uncommitted Ticket 검증
    • INCORRECT CARD NUMBER, VERIFY COMMISSION
    • 부분 발권 실패 감지
  8. 비동기 취소: 5초 대기 → Void + 결제취소 + PNR취소
  9. Session 관리: try-finally 패턴, closeSessionToken 실패 시 Slack 알림

12.2 코드 참조 요약

기능파일라인
Ready (Deprecated)SabreTicketingService.kt26-50
IssueSabreTicketingService.kt52-134
결제 분기SabrePaymentService.kt23-49
PR EndorsementSabreTicketingService.kt83
MU DOB TicketsAirTicketRQ.kt114-152
Individual TicketsAirTicketRQ.kt78-112
Grouped TicketsAirTicketRQ.kt154-193
Convert CommissionAirTicketRQ.kt195-224
FareBasis 변경 검증SabreTicketingService.kt199-216
Retrieve TicketSabreTicketingService.kt166-197
Cancel AsyncSabreTicketingService.kt136-164
Ticketing APISabreClient.kt493-565
MiscQualifierMiscQualifier.kt30-44
EndorsementMiscQualifier.kt67-79

문서 버전: 1.0 작성일: 2025-09-30 분석 대상: Sabre GDS Ticketing API (SOAP) 참조 프로젝트: air-intl-adapter