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 != null | passenger.identity | 승객별 고유 Identity 코드 |
| identity == null | passenger.type.sabre | PassengerType enum의 Sabre 코드 (ADT, CNN, INF) |
예시:
// Identity 있는 경우
PassengerType(code = "JCB", quantity = 2)
// Identity 없는 경우
PassengerType(code = "ADT", quantity = 2) // Adult
PassengerType(code = "CNN", quantity = 1) // Child3.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
검증 항목
- 승객 매칭: type + identificationKey로 원본 승객 찾기
- FareComponents 비교: 콤마 구분 문자열로 변환 후 비교
- 변경 감지: 하나라도 다르면 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의 역할
- 세션 변경사항 저장: 메모리에 있는 변경사항을 PNR에 저장
- PriceQuote 저장: Retain=true로 생성된 PriceQuote 저장
- 트랜잭션 종료: 현재 세션의 트랜잭션 완료
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 비교
| 항목 | Amadeus | Sabre |
|---|---|---|
| 운임 객체 | TST (Transitional Stored Ticket) | PriceQuote |
| 생성 API | Fare_PricePNRWithBookingClass | OtaAirPriceRQ |
| 삭제 API | TstDelete | DeletePriceQuoteRQ |
| Repricing 조건 | 명시적 조건 없음 | priceQuoteCreatedAt != today() |
| Account Code | CorporateId 파라미터 | FareComponents에서 추출 |
| PassengerType | 개별 지정 | Identity/Type 그룹핑 |
| FareBasis 검증 | 없음 | validateFareBasisChange |
| Endorsement 처리 | Pricing 단계에서 처리 | Ticketing 단계에서 처리 |
| TST/PQ 저장 | TstCreate → savePnrWithRetrieve | Retain=true → endTransaction |
| 세션 관리 | Start → InSeries → End | getSessionToken → 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 Enum | type.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 프로세스 핵심
- PriceQuote 생성일 확인:
priceQuoteCreatedAt != today()→ Repricing - DeletePriceQuote: 기존 PriceQuote 삭제
- OtaAirPriceRQ: 새 PriceQuote 생성
- Account Code 추출 (FareComponents)
- PassengerType 그룹핑 (Identity 또는 Type.sabre)
- Retain=true (PriceQuote 저장)
- EndTransaction: PriceQuote를 PNR에 저장
- GetBooking: 새 PriceQuote 확인
- 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 - Repricing | SabreTicketingService.kt | 26-50 |
| PriceQuote 생성일 확인 | SabreTicketingService.kt | 32-40 |
| FareBasis 검증 | SabreTicketingService.kt | 199-216 |
| DeletePriceQuote | SabreClient.kt | 457-473 |
| Repricing | SabreClient.kt | 475-491 |
| EndTransaction | SabreClient.kt | 567-583 |
| OtaAirPriceRQ 구조 | OtaAirPriceRQ.kt | 10-44 |
| Account Code 추출 | OtaAirPriceRQ.kt | 26-30 |
| PassengerType 그룹핑 | OtaAirPriceRQ.kt | 31-37 |
| DeletePriceQuoteRQ | DeletePriceQuoteRQ.kt | 8-17 |
| PriceRequestInformation | PriceRequestInformation.kt | 5-11 |
| Booking 구조 | Booking.kt | 10-22 |
문서 버전: 1.0 작성일: 2025-09-30 분석 대상: Sabre GDS Pricing API (SOAP) 참조 프로젝트: air-intl-adapter