Phase 4: Sabre 운임 계산(Pricing) API 심층 분석

1. API 개요

1.1 주요 기능

  • PriceQuote 삭제: 기존 PriceQuote 제거 (DeletePriceQuoteRQ)
  • Repricing: 새로운 PriceQuote 생성 (OtaAirPriceRQ)
  • 생성일 기반 검증: PriceQuote 생성일 != 오늘 → Repricing
  • Account Code 적용: FareComponent에서 Account Code 추출
  • PassengerType 그룹핑: Identity 또는 Type별 그룹핑
  • FareBasis 검증: Repricing 후 변경 감지

1.2 관련 파일 구조

supplier/sabre/
├── application/
│   └── SabreTicketingService.kt       # Ready API - Repricing 로직
├── infrastructure/soap/
│   ├── SabreClient.kt                 # deletePriceQuote, repricing
│   └── request/
│       ├── deletepricequote/
│       │   └── DeletePriceQuoteRQ.kt  # PriceQuote 삭제 요청
│       └── otaairprice/
│           └── OtaAirPriceRQ.kt       # Repricing 요청
└── support/model/
    └── Booking.kt                     # priceQuoteCreatedAt 필드

2. PriceQuote 삭제 (DeletePriceQuoteRQ)

2.1 DeletePriceQuoteRQ 구조

@JacksonXmlRootElement(localName = "DeletePriceQuoteRQ", namespace = "http://webservices.sabre.com/sabreXML/2011/10")
data class DeletePriceQuoteRQ(
    @JacksonXmlProperty(isAttribute = true, localName = "Version")
    val version: String = "2.1.0",
 
    @JacksonXmlProperty(localName = "AirItineraryPricingInfo")
    val airItineraryPricingInfo: AirItineraryPricingInfo = AirItineraryPricingInfo()
) : SabreRequest {
    override var action: String = "DeletePriceQuoteLLSRQ"
    override var token: String? = null
}

위치: DeletePriceQuoteRQ.kt:8-17

특징

  • 기본 구조: 별도 파라미터 없이 PNR의 모든 PriceQuote 삭제
  • Version: 2.1.0
  • Action: DeletePriceQuoteLLSRQ (SOAP Action)

2.2 deletePriceQuote 메서드

fun deletePriceQuote(token: String) {
    val sabreApiProperties = sabreProperties.getApiProperties()
    val request = DeletePriceQuoteRQ().withToken(token)
    return sabreApiProperties.endpoint
        .post(request)
        .header(headerMap)
        .requestBodyConvert(soapRequestBodyConverter(sabreApiProperties))
        .execute<SabreResponse<DeletePriceQuoteRS>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body!!.checkError()
            },
            failure = {
                throw it.handleSoapFaultException(ErrorMessage.REPRICING_FAILED)
            }
        )
}

위치: SabreClient.kt:457-473

에러 처리

  • ErrorMessage: REPRICING_FAILED
  • 목적: PriceQuote 삭제 실패를 Repricing 실패로 간주
  • SOAP Fault: handleSoapFaultException으로 처리

3. Repricing (OtaAirPriceRQ)

3.1 OtaAirPriceRQ 구조

@JacksonXmlRootElement(localName = "OTA_AirPriceRQ", namespace = "http://webservices.sabre.com/sabreXML/2011/10")
data class OtaAirPriceRQ(
    @JacksonXmlProperty(isAttribute = true, localName = "Version")
    val version: String = "2.17.0",
 
    @JacksonXmlProperty(localName = "PriceRequestInformation")
    val priceRequestInformation: PriceRequestInformation,
) : SabreRequest {
    override var action: String = "OTA_AirPriceLLSRQ"
    override var token: String? = null
 
    companion object {
        fun of(passengers: List<Passenger>): OtaAirPriceRQ {
            return OtaAirPriceRQ(
                priceRequestInformation = PriceRequestInformation(
                    optionalQualifier = OptionalQualifier(
                        pricingQualifier = PricingQualifier(
                            account = passengers.find {
                                it.fare?.fareComponents?.any { fareComponent -> fareComponent.accountCode != null }
                                    ?: false
                            }?.fare?.fareComponents?.firstOrNull { it.accountCode != null }?.accountCode
                                ?.let { Account(code = it) },
                            passengerTypes = passengers.groupBy { it.identity ?: it.type.sabre }
                                .mapNotNull { (identity, passengers) ->
                                    PassengerType(
                                        code = identity,
                                        quantity = passengers.size
                                    )
                                }
                        )
                    )
                )
            )
        }
    }
}

위치: OtaAirPriceRQ.kt:10-44

3.2 PriceRequestInformation 구조

data class PriceRequestInformation(
    @JacksonXmlProperty(isAttribute = true, localName = "Retain")
    val retain: Boolean = true,
 
    @JacksonXmlProperty(localName = "OptionalQualifiers")
    val optionalQualifier: OptionalQualifier,
)

위치: PriceRequestInformation.kt:5-11

Retain 플래그

  • 기본값: true
  • 의미: PriceQuote를 PNR에 저장 (endTransaction 시 저장됨)
  • 목적: Repricing 결과를 유지하여 발권 시 사용

3.3 Account Code 추출 로직

account = passengers.find {
    it.fare?.fareComponents?.any { fareComponent -> fareComponent.accountCode != null }
        ?: false
}?.fare?.fareComponents?.firstOrNull { it.accountCode != null }?.accountCode
    ?.let { Account(code = it) }

위치: OtaAirPriceRQ.kt:26-30

추출 전략

flowchart TD
    A[모든 승객 순회] --> B{Account Code<br/>있는 승객 찾기}
    B -->|발견| C[해당 승객의 Fare 조회]
    B -->|없음| D[null 반환]

    C --> E[FareComponents 순회]
    E --> F{accountCode<br/>!= null?}
    F -->|Yes| G[첫 번째 Account Code 선택]
    F -->|No| H[다음 Component]

    G --> I[Account 객체 생성]
    H --> E
    I --> J[PriceRequestInformation에 포함]

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

Identity vs Type.sabre

조건사용 값설명
identity != nullpassenger.identity승객별 고유 Identity 코드
identity == nullpassenger.type.sabrePassengerType enum의 Sabre 코드 (ADT, CNN, INF)

예시:

// Identity 있는 경우
PassengerType(code = "JCB", quantity = 2)
 
// Identity 없는 경우
PassengerType(code = "ADT", quantity = 2)  // Adult
PassengerType(code = "CNN", quantity = 1)  // Child

3.5 repricing 메서드

fun repricing(token: String, passengers: List<Passenger>) {
    val sabreApiProperties = sabreProperties.getApiProperties()
    val request = OtaAirPriceRQ.of(passengers).withToken(token)
    return sabreApiProperties.endpoint
        .post(request)
        .header(headerMap)
        .requestBodyConvert(soapRequestBodyConverter(sabreApiProperties))
        .execute<SabreResponse<OtaAirPriceRS>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body!!.checkError()
            },
            failure = {
                throw it.handleSoapFaultException(ErrorMessage.REPRICING_FAILED)
            }
        )
}

위치: SabreClient.kt:475-491

특징

  • 입력: Session Token + Passengers
  • 처리: OtaAirPriceRQ.of(passengers) - Account Code, PassengerType 추출
  • 에러: REPRICING_FAILED
  • 저장: retain=true로 PriceQuote 자동 저장 (endTransaction 필요)

4. Repricing 프로세스 (Ready API)

4.1 전체 Repricing 플로우

flowchart TD
    A[Ready API 호출] --> B[Session Token 획득]
    B --> C[PNR 정보 조회<br/>getBooking]
    C --> D[발권 가능 검증<br/>validateBookingConditionForTicketing]

    D --> E{PriceQuote<br/>생성일 확인}
    E -->|today| F[Repricing 불필요<br/>null to schedules 반환]
    E -->|today 아님| G[deletePriceQuote<br/>기존 PQ 삭제]

    G --> H[repricing<br/>OtaAirPriceRQ]
    H --> I[endTransaction<br/>PQ 저장]
    I --> J[getBooking<br/>PNR 재조회]
    J --> K[validateFareBasisChange<br/>FareBasis 검증]

    K --> L{FareBasis<br/>변경?}
    L -->|Yes| M[onlyPnrCancel<br/>PNR 취소]
    L -->|No| N[passengers to schedules 반환]

    M --> O[SOLD_OUT 예외 발생]

    F --> P[closeSessionToken]
    N --> P
    O --> P
    P --> Q[결과 반환 또는 예외]

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

검증 항목

  1. 승객 매칭: type + identificationKey로 원본 승객 찾기
  2. FareComponents 비교: 콤마 구분 문자열로 변환 후 비교
  3. 변경 감지: 하나라도 다르면 PNR 취소 후 SOLD_OUT 예외

예시:

// 정상 케이스
originFareBasis = "Y1RT,Y1RT"
newFareBasis = "Y1RT,Y1RT"
// → 통과
 
// 변경 케이스 (운임 코드 변경)
originFareBasis = "Y1RT,Y1RT"
newFareBasis = "Y2RT,Y2RT"
// → PNR 취소 + SOLD_OUT 예외

5. EndTransaction과 PriceQuote 저장

5.1 endTransaction 메서드

fun endTransaction(token: String) {
    val sabreApiProperties = sabreProperties.getApiProperties()
    val request = EndTransactionRQ().withToken(token)
    return sabreApiProperties.endpoint
        .post(request)
        .header(headerMap)
        .requestBodyConvert(soapRequestBodyConverter(sabreApiProperties))
        .execute<SabreResponse<EndTransactionRS>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body!!.checkError()
            },
            failure = {
                throw it.handleSoapFaultException(ErrorMessage.INTERNAL_SERVER_ERROR)
            }
        )
}

위치: SabreClient.kt:567-583

5.2 Repricing 후 endTransaction 역할

sequenceDiagram
    participant S as SabreTicketingService
    participant C as SabreClient
    participant GDS as Sabre GDS

    S->>C: deletePriceQuote(token)
    C->>GDS: DeletePriceQuoteRQ
    GDS-->>C: 삭제 완료 (세션에만 반영)

    S->>C: repricing(token, passengers)
    C->>GDS: OtaAirPriceRQ (Retain=true)
    GDS-->>C: PriceQuote 생성 (세션에만 반영)

    S->>C: endTransaction(token)
    C->>GDS: EndTransactionRQ
    Note over GDS: PriceQuote를 PNR에 저장
    GDS-->>C: 저장 완료

    S->>C: getBooking(token, pnr)
    C->>GDS: GetReservationRQ
    GDS-->>C: 새 PriceQuote 포함된 PNR

EndTransaction의 역할

  1. 세션 변경사항 저장: 메모리에 있는 변경사항을 PNR에 저장
  2. PriceQuote 저장: Retain=true로 생성된 PriceQuote 저장
  3. 트랜잭션 종료: 현재 세션의 트랜잭션 완료

6. Booking 데이터 구조

6.1 priceQuoteCreatedAt 필드

data class Booking(
    val pnr: String?,
    val validatingCarrier: String,
    val schedules: List<Schedule>?,
    val passengers: List<Passenger>,
    val parentPnrs: List<String>?,
    val carrierTimeLimit: LocalDateTime?,
    val paymentTimeLimit: LocalDateTime?,
    val pnrCreatedAt: LocalDateTime,
    val priceQuoteCreatedAt: LocalDateTime,  // PriceQuote 생성 일시
    val payment: Payment?,
    val ticketHistories: List<TicketHistory>?,
)

위치: Booking.kt:10-22

priceQuoteCreatedAt의 용도

  • Repricing 조건: priceQuoteCreatedAt.toLocalDate() != today()
  • 목적: 당일 PriceQuote는 최신 상태로 간주, 이전 날짜는 재계산
  • 타입: LocalDateTime (날짜 + 시간)
  • 비교: .toLocalDate()로 날짜만 비교

7. Amadeus vs Sabre Pricing 비교

항목AmadeusSabre
운임 객체TST (Transitional Stored Ticket)PriceQuote
생성 APIFare_PricePNRWithBookingClassOtaAirPriceRQ
삭제 APITstDeleteDeletePriceQuoteRQ
Repricing 조건명시적 조건 없음priceQuoteCreatedAt != today()
Account CodeCorporateId 파라미터FareComponents에서 추출
PassengerType개별 지정Identity/Type 그룹핑
FareBasis 검증없음validateFareBasisChange
Endorsement 처리Pricing 단계에서 처리Ticketing 단계에서 처리
TST/PQ 저장TstCreate → savePnrWithRetrieveRetain=true → endTransaction
세션 관리Start → InSeries → EndgetSessionToken → endTransaction → closeSessionToken
API 타입SOAP (Fare_PricePNRWithBookingClass)SOAP (OTA_AirPriceLLSRQ)

8. Repricing 시나리오별 분석

8.1 시나리오 1: 당일 PriceQuote (Repricing 생략)

sequenceDiagram
    participant U as User
    participant S as SabreTicketingService
    participant C as SabreClient

    U->>S: ready(pnr)
    S->>C: getSessionToken()
    C-->>S: token

    S->>C: getBooking(token, pnr)
    C-->>S: booking (priceQuoteCreatedAt: 오늘 10:00)

    Note over S: priceQuoteCreatedAt.toLocalDate() == today()
    Note over S: Repricing 생략

    S->>C: closeSessionToken(token)
    S-->>U: null to booking.schedules

8.2 시나리오 2: 이전 PriceQuote (Repricing 수행)

sequenceDiagram
    participant U as User
    participant S as SabreTicketingService
    participant C as SabreClient

    U->>S: ready(pnr)
    S->>C: getSessionToken()
    C-->>S: token

    S->>C: getBooking(token, pnr)
    C-->>S: booking (priceQuoteCreatedAt: 어제)

    Note over S: priceQuoteCreatedAt.toLocalDate() != today()
    Note over S: Repricing 필요

    S->>C: deletePriceQuote(token)
    C-->>S: 삭제 완료

    S->>C: repricing(token, booking.passengers)
    C-->>S: 새 PriceQuote 생성

    S->>C: endTransaction(token)
    C-->>S: PNR 저장 완료

    S->>C: getBooking(token, pnr)
    C-->>S: newBooking

    S->>S: validateFareBasisChange(booking, newBooking)
    Note over S: FareBasis 동일 확인

    S->>C: closeSessionToken(token)
    S-->>U: newBooking.passengers to newBooking.schedules

8.3 시나리오 3: FareBasis 변경 감지

sequenceDiagram
    participant U as User
    participant S as SabreTicketingService
    participant CS as CancelService

    Note over S: Repricing 완료 후 getBooking

    S->>S: validateFareBasisChange(originBooking, newBooking)
    Note over S: originFareBasis: "Y1RT,Y1RT"
    Note over S: newFareBasis: "Y2RT,Y2RT"
    Note over S: FareBasis 변경 감지!

    S->>CS: onlyPnrCancel(pnr)
    CS-->>S: PNR 취소 완료

    S-->>U: StatusInvalidException(SOLD_OUT)
    Note over U: "ADT fareBasis is changed (Y1RT,Y1RT->Y2RT,Y2RT)"

9. Account Code 처리 상세

9.1 Account Code 추출 예시

// Passenger 1: Account Code 있음
Passenger(
    fare = Fare(
        fareComponents = listOf(
            FareComponent(accountCode = "CORP123"),
            FareComponent(accountCode = null)
        )
    )
)
 
// Passenger 2: Account Code 없음
Passenger(
    fare = Fare(
        fareComponents = listOf(
            FareComponent(accountCode = null)
        )
    )
)
 
// 결과: "CORP123" 추출 (첫 번째 발견값)

9.2 Account Code 적용 플로우

flowchart TD
    A[OtaAirPriceRQ.of 호출] --> B[passengers 순회]
    B --> C{FareComponents 중<br/>accountCode != null<br/>있는가?}

    C -->|Yes| D[첫 번째 승객 선택]
    C -->|No| E[Account Code 없음<br/>account = null]

    D --> F[FareComponents 순회]
    F --> G{accountCode<br/>!= null?}

    G -->|Yes| H[첫 번째 accountCode 선택]
    G -->|No| I[다음 FareComponent]

    H --> J[Account 객체 생성<br/>Account.code = accountCode]
    I --> F

    J --> K[PricingQualifier에 포함]
    E --> K

    K --> L[OtaAirPriceRQ 생성<br/>Sabre GDS로 전송]
    L --> M[Account Code 적용된<br/>PriceQuote 생성]

    %% 다크/라이트 모드 호환 색상
    style C fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style G fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style J fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
    style M fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

9.3 Account Code의 의미

  • 목적: 기업 할인 코드 또는 협약 코드
  • 효과: 특정 요금 할인 또는 특별 운임 적용
  • 적용: Sabre GDS가 Account Code를 인식하여 할인 운임 계산
  • 제한: 하나의 Account Code만 적용 (첫 번째 발견값 사용)

10. PassengerType 그룹핑 상세

10.1 Identity 기반 그룹핑 예시

// 입력 승객
val passengers = listOf(
    Passenger(identity = "JCB", type = PassengerType.ADULT),  // Identity 있음
    Passenger(identity = "JCB", type = PassengerType.ADULT),  // Identity 있음
    Passenger(identity = null, type = PassengerType.CHILD),   // Identity 없음
)
 
// 그룹핑 결과
passengers.groupBy { it.identity ?: it.type.sabre }
// → { "JCB": [승객1, 승객2], "CNN": [승객3] }
 
// PassengerType 생성
listOf(
    PassengerType(code = "JCB", quantity = 2),
    PassengerType(code = "CNN", quantity = 1)
)

10.2 Type.sabre 기반 그룹핑 예시

// 입력 승객 (Identity 없음)
val passengers = listOf(
    Passenger(identity = null, type = PassengerType.ADULT),
    Passenger(identity = null, type = PassengerType.ADULT),
    Passenger(identity = null, type = PassengerType.CHILD),
    Passenger(identity = null, type = PassengerType.INFANT),
)
 
// 그룹핑 결과
passengers.groupBy { it.identity ?: it.type.sabre }
// → { "ADT": [승객1, 승객2], "CNN": [승객3], "INF": [승객4] }
 
// PassengerType 생성
listOf(
    PassengerType(code = "ADT", quantity = 2),
    PassengerType(code = "CNN", quantity = 1),
    PassengerType(code = "INF", quantity = 1)
)

10.3 PassengerType 코드 매핑

PassengerType Enumtype.sabre설명
ADULT”ADT”성인
CHILD”CNN”소아
INFANT”INF”유아

11. 에러 처리 및 복구

11.1 DeletePriceQuote 실패

failure = {
    throw it.handleSoapFaultException(ErrorMessage.REPRICING_FAILED)
}

위치: SabreClient.kt:469-471

  • ErrorMessage: REPRICING_FAILED
  • 영향: Repricing 프로세스 중단
  • 복구: 재시도 또는 사용자에게 알림

11.2 Repricing 실패

failure = {
    throw it.handleSoapFaultException(ErrorMessage.REPRICING_FAILED)
}

위치: SabreClient.kt:487-489

  • ErrorMessage: REPRICING_FAILED
  • 영향: 새 PriceQuote 생성 실패
  • 복구: 기존 PriceQuote는 이미 삭제됨 → PNR 상태 불안정

11.3 FareBasis 변경 감지

if (originFareBasis != newFareBasis) {
    cancelService.onlyPnrCancel(pnr = originBooking.pnr!!)
    throw StatusInvalidException(
        ErrorMessage.SOLD_OUT,
        "${newPassenger.type.name} fareBasis is changed ($originFareBasis->$newFareBasis)",
    ).capture()
}

위치: SabreTicketingService.kt:207-214

  • ErrorMessage: SOLD_OUT
  • 복구 동작: PNR 취소 (onlyPnrCancel)
  • 이유: 운임 변경으로 기존 조건 불가능
  • 사용자 메시지: FareBasis 변경 정보 포함

12. 요약 및 핵심 포인트

12.1 Pricing 프로세스 핵심

  1. PriceQuote 생성일 확인: priceQuoteCreatedAt != today() → Repricing
  2. DeletePriceQuote: 기존 PriceQuote 삭제
  3. OtaAirPriceRQ: 새 PriceQuote 생성
    • Account Code 추출 (FareComponents)
    • PassengerType 그룹핑 (Identity 또는 Type.sabre)
    • Retain=true (PriceQuote 저장)
  4. EndTransaction: PriceQuote를 PNR에 저장
  5. GetBooking: 새 PriceQuote 확인
  6. FareBasis 검증: 변경 시 PNR 취소 + SOLD_OUT

12.2 Amadeus와의 주요 차이점

  • Sabre: PriceQuote 생성일 기반 자동 Repricing
  • Amadeus: 명시적 Repricing 조건 없음
  • Sabre: Account Code 자동 추출
  • Amadeus: CorporateId 파라미터로 전달
  • Sabre: FareBasis 변경 검증 강제
  • Amadeus: FareBasis 검증 없음

12.3 코드 참조 요약

기능파일라인
Ready API - RepricingSabreTicketingService.kt26-50
PriceQuote 생성일 확인SabreTicketingService.kt32-40
FareBasis 검증SabreTicketingService.kt199-216
DeletePriceQuoteSabreClient.kt457-473
RepricingSabreClient.kt475-491
EndTransactionSabreClient.kt567-583
OtaAirPriceRQ 구조OtaAirPriceRQ.kt10-44
Account Code 추출OtaAirPriceRQ.kt26-30
PassengerType 그룹핑OtaAirPriceRQ.kt31-37
DeletePriceQuoteRQDeletePriceQuoteRQ.kt8-17
PriceRequestInformationPriceRequestInformation.kt5-11
Booking 구조Booking.kt10-22

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