Singapore Airlines Pricing API 심층 분석
1. API 엔드포인트 개요
1.1 Pricing 관련 API
| 기능 | API 타입 | 호출 위치 | 목적 |
|---|---|---|---|
| 예약 전 Pricing | OfferPriceRQ | SingaporeairBookingService.kt:44-49 | 예약 전 운임 재계산 |
| 재발행 Pricing | OrderReshopRQ | SingaporeairClient.kt:700-729 | 재발행 운임 재계산 |
2. 예약 전 Pricing
2.1 전체 플로우
flowchart TD A[Pricing 요청] --> B[FareItinerary<br/>준비] B --> C[OfferPriceRQ<br/>생성] C --> D[SOAP 요청<br/>실행] D --> E{에러 체크} E -->|에러| F[PRICING_FAILED<br/>예외 발생] E -->|정상| G[응답 파싱<br/>PassengerFare 변환] G --> H[Pricing 완료<br/>PassengerFare 반환] style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000 style D fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000 style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style F fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style H fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
2.2 상세 분석
위치: SingaporeairClient.kt:215-248
fun pricing(adult: Int, child: Int, infant: Int, fareItinerary: FareItinerary): List<PassengerFare> {
val singaporeairApiProperties = singaporeairProperties.getApiProperties()
val request = OfferPriceRQ.of(
adult = adult,
child = child,
infant = infant,
fareItinerary = fareItinerary,
iataNumber = singaporeairApiProperties.iataCode,
agencyName = singaporeairApiProperties.agencyName
)
return singaporeairApiProperties.endpoint
.post(request)
.header(getHeaderMap(request))
.requestBodyConvert(soapRequestBodyConverter(singaporeairApiProperties))
.execute<OfferPriceRS>(soapBodyDeserializerOf(logger, objectMapper))
.fold(
success = { offerPriceRS ->
offerPriceRS.also {
it.checkError { code, message ->
throw InternationalAdapterException(ErrorMessage.PRICING_FAILED, code, message).capture()
}
}.response!!.let { response ->
response.pricedOffer.offer.offerItems.first().fareDetails!!.map {
it.toPassengerFare(response.dataList.paxs)
}
}
},
failure = { failure ->
throw failure.handleSoapFaultException(ErrorMessage.PRICING_FAILED)
}
)
}3. OfferPriceRQ 구조
3.1 주요 필드
위치: infrastructure/request/OfferPriceRQ.kt
data class OfferPriceRQ(
val query: Query,
) : SingaporeairRequest {
override val action = "http://www.iata.org/IATA/2015/00/2018.2/OfferPriceRQ"
data class Query(
val pricedOffer: PricedOffer,
)
companion object {
fun of(
adult: Int,
child: Int,
infant: Int,
fareItinerary: FareItinerary,
iataNumber: String,
agencyName: String,
): OfferPriceRQ {
return OfferPriceRQ(
query = Query(
pricedOffer = PricedOffer.of(
adult = adult,
child = child,
infant = infant,
fareItinerary = fareItinerary,
iataNumber = iataNumber,
agencyName = agencyName
)
)
)
}
}
}3.2 PricedOffer 구조
data class PricedOffer(
val selectedOffer: SelectedOffer, // 선택한 항공편
val shoppingCriteria: ShoppingCriteria, // 검색 조건 (승객 정보)
val pointOfSale: PointOfSale, // 판매 지점
) {
companion object {
fun of(
adult: Int,
child: Int,
infant: Int,
fareItinerary: FareItinerary,
iataNumber: String,
agencyName: String,
): PricedOffer {
return PricedOffer(
selectedOffer = SelectedOffer(
selectedOfferItem = SelectedOfferItem(
offerId = OfferId(value = fareItinerary.id.split("_").first()),
offerItemRefId = OfferItemRefId(value = fareItinerary.itemKey)
),
shoppingResponseRefId = ShoppingResponseRefId(value = fareItinerary.key)
),
shoppingCriteria = ShoppingCriteria(
paxs = listOf(
if (adult > 0) Pax(ptc = PTC(value = "ADT"), qty = Qty(value = adult)) else null,
if (child > 0) Pax(ptc = PTC(value = "CHD"), qty = Qty(value = child)) else null,
if (infant > 0) Pax(ptc = PTC(value = "INF"), qty = Qty(value = infant)) else null,
).filterNotNull()
),
pointOfSale = PointOfSale(
country = Country(countryCode = "KR"),
agencyID = AgencyID(value = iataNumber),
agencyName = AgencyName(value = agencyName),
)
)
}
}
}4. OfferPriceRS 구조
4.1 주요 필드
위치: infrastructure/response/OfferPriceRS.kt
data class OfferPriceRS(
val response: Response?,
val errors: List<Error>?,
) {
data class Response(
val pricedOffer: PricedOffer, // 가격 정보
val dataList: DataList, // 참조 데이터
val warnings: List<Warning>?, // 경고
)
}4.2 PricedOffer 구조
data class PricedOffer(
val offer: Offer, // Offer 정보
)
data class Offer(
val offerId: OfferId,
val offerItems: List<OfferItem>,
val totalPrice: TotalPrice, // 총 가격
)
data class OfferItem(
val offerItemId: OfferItemId,
val price: Price, // 가격
val fareDetails: List<FareDetail>?, // 운임 상세
)4.3 FareDetail 구조
data class FareDetail(
val paxRefId: PaxRefId, // 승객 참조 ID
val price: Price, // 가격
val fareComponents: List<FareComponent>?, // 운임 구성 요소
)
data class Price(
val baseAmount: BaseAmount?, // 기본 운임
val taxes: Taxes?, // 세금
val totalAmount: TotalAmount?, // 총 금액
)4.4 PassengerFare 변환
위치: FareDetail.kt (extension function)
fun FareDetail.toPassengerFare(paxs: List<Pax>): PassengerFare {
val pax = paxs.find { it.id == this.paxRefId.value }
?: throw InternationalAdapterException(ErrorMessage.PRICING_FAILED, "Pax not found: ${this.paxRefId.value}")
return PassengerFare(
type = pax.ptc.toPassengerType(),
count = pax.qty ?: 1,
airPrice = this.price.baseAmount?.value?.toLong() ?: 0L,
tax = this.price.taxes?.total?.value?.toLong() ?: 0L,
total = this.price.totalAmount?.value?.toLong() ?: 0L,
carrierFee = this.price.surcharges?.total?.value?.toLong(),
)
}PassengerFare 구조:
data class PassengerFare(
val type: PassengerType, // ADT, CHD, INF
val count: Int, // 인원 수
val airPrice: Long, // 기본 운임
val tax: Long, // 세금
val total: Long, // 총 금액 (airPrice + tax)
val carrierFee: Long?, // 항공사 수수료
)5. 재발행 Pricing
5.1 전체 플로우
flowchart TD A[Repricing 요청] --> B[OrderReshopRQ<br/>생성] B --> C[SOAP 요청<br/>실행] C --> D{에러 체크} D -->|에러| E[PRICING_FAILED<br/>예외 발생] D -->|정상| F[응답 파싱<br/>FareItinerary 변환] F --> G[Repricing 완료<br/>FareItinerary 반환] style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000 style C fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000 style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style E fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style G fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
5.2 상세 분석
위치: SingaporeairClient.kt:700-729
fun repricingWithReissue(
booking: Booking,
fareItinerary: FareItinerary,
): FareItinerary {
val singaporeairApiProperties = singaporeairProperties.getApiProperties()
val request = OrderReshopRQ.ofPricing(
booking = booking,
iataNumber = singaporeairApiProperties.iataCode,
agencyName = singaporeairApiProperties.agencyName,
fareItinerary = fareItinerary
)
return singaporeairApiProperties.endpoint
.post(request)
.header(getHeaderMap(request))
.requestBodyConvert(soapRequestBodyConverter(singaporeairApiProperties))
.execute<OrderReshopRS>(soapBodyDeserializerOf(logger, objectMapper))
.fold(
success = { orderReshopRS ->
orderReshopRS.checkError { _, _ ->
throw InternationalAdapterException(ErrorMessage.PRICING_FAILED).capture()
}
orderReshopRS.response!!.toFareItinerary(key = fareItinerary.key)
},
failure = {
throw it.exception
}
)
}6. OrderReshopRQ.ofPricing 구조
6.1 주요 필드
위치: infrastructure/request/OrderReshopRQ.kt
data class OrderReshopRQ(
val query: Query,
) : SingaporeairRequest {
override val action = "http://www.iata.org/IATA/2015/00/2018.2/OrderReshopRQ"
companion object {
fun ofPricing(
booking: Booking,
iataNumber: String,
agencyName: String,
fareItinerary: FareItinerary,
): OrderReshopRQ {
return OrderReshopRQ(
query = Query(
order = Order.ofPricing(
booking = booking,
fareItinerary = fareItinerary,
iataNumber = iataNumber,
agencyName = agencyName
)
)
)
}
}
}6.2 Order.ofPricing 구조
data class Order(
val orderId: OrderId, // 원본 PNR
val deleteOrderItems: List<DeleteOrderItem>?, // 삭제할 항목
val addOrderItems: List<AddOrderItem>?, // 추가할 항목
val pointOfSale: PointOfSale, // 판매 지점
) {
companion object {
fun ofPricing(
booking: Booking,
fareItinerary: FareItinerary,
iataNumber: String,
agencyName: String,
): Order {
return Order(
orderId = OrderId(value = booking.pnr),
deleteOrderItems = booking.schedules!!.map { schedule ->
DeleteOrderItem(
orderItemRefId = OrderItemRefId(value = schedule.orderItemId)
)
},
addOrderItems = listOf(
AddOrderItem.of(fareItinerary)
),
pointOfSale = PointOfSale(
country = Country(countryCode = "KR"),
agencyID = AgencyID(value = iataNumber),
agencyName = AgencyName(value = agencyName),
)
)
}
}
}7. OrderReshopRS 구조
7.1 주요 필드
위치: infrastructure/response/OrderReshopRS.kt
data class OrderReshopRS(
val response: Response?,
val errors: List<Error>?,
) {
data class Response(
val reshopResult: ReshopResult, // 재구매 결과
val dataList: DataList, // 참조 데이터
)
}7.2 ReshopResult 구조
data class ReshopResult(
val reshopOffers: List<ReshopOffer>, // 재구매 제안
)
data class ReshopOffer(
val id: String,
val addOrderItems: List<AddOrderItem>?, // 추가할 항목
val deleteOrderItems: List<DeleteOrderItem>?, // 삭제할 항목
val priceComparison: PriceComparison?, // 가격 비교
)7.3 FareItinerary 변환
위치: OrderReshopRS.kt (extension function)
fun Response.toFareItinerary(key: String): FareItinerary {
val reshopOffer = this.reshopResult.reshopOffers.first()
return FareItinerary(
key = key,
passengerFares = reshopOffer.addOrderItems!!.flatMap { addOrderItem ->
addOrderItem.fareDetails.map { fareDetail ->
fareDetail.toPassengerFare(this.dataList.paxs)
}
},
// ... 기타 필드
)
}8. OfferPriceRQ의 다중 용도
8.1 용도별 분류
OfferPriceRQ는 NDC 표준에서 다음 3가지 용도로 사용됨:
| 용도 | 메소드 | 반환 타입 | 목적 |
|---|---|---|---|
| 1. Pricing | pricing() | List<PassengerFare> | 운임 재계산 |
| 2. MiniRules | getMiniRules() | List<MiniRule> | 운임 규정 조회 |
| 3. Ancillary Repricing | repricingWithAncillary() | Long | 부가서비스 가격 재계산 |
8.2 각 용도별 응답 파싱
1. Pricing 응답 파싱
response.pricedOffer.offer.offerItems.first().fareDetails!!.map {
it.toPassengerFare(response.dataList.paxs)
}2. MiniRules 응답 파싱
response.pricedOffer.offer.offerItems.flatMap { offerItem ->
offerItem.fareDetails?.first()?.fareComponents?.mapNotNull { fareComponent ->
response.dataList.priceClasses.find { priceClass -> priceClass.id == fareComponent.priceClassRef }
?.toMiniRule(response.dataList.paxSegments.find { paxSegment -> paxSegment.id == fareComponent.segmentRef })
}
}3. Ancillary Repricing 응답 파싱
it.response!!.pricedOffer.offer.offerItems.sumOf { item ->
item.price.totalAmount?.value?.toLong() ?: 0L
}9. 에러 처리
9.1 에러 타입
| 에러 타입 | 발생 시점 | 처리 방법 |
|---|---|---|
PRICING_FAILED | Pricing | InternationalAdapterException (Sentry 캡처) |
PRICING_FAILED | Repricing | InternationalAdapterException (Sentry 캡처) |
9.2 에러 처리 로직
offerPriceRS.checkError { code, message ->
throw InternationalAdapterException(ErrorMessage.PRICING_FAILED, code, message).capture()
}특징:
- 모든 에러를
PRICING_FAILED로 처리 - Sentry 자동 캡처
- Galileo처럼 SOLD_OUT 별도 처리 없음
10. GDS와의 차이점 비교
| 항목 | Singapore Air (NDC) | Galileo | Amadeus | Sabre |
|---|---|---|---|---|
| Pricing API | OfferPriceRQ | AirPriceRQ | FarePricePNR | PriceQuote |
| 재발행 Pricing | OrderReshopRQ | N/A | N/A | N/A |
| SOLD_OUT 처리 | Book 단계 | Pricing 단계 | Pricing 단계 | Pricing 단계 |
| PassengerFare 구조 | type, count, airPrice, tax, total, carrierFee | 유사 | 유사 | 유사 |
| 다중 용도 | Pricing, MiniRules, Ancillary | Pricing만 | Pricing만 | Pricing만 |
11. 주요 발견사항
11.1 Singapore Airlines NDC Pricing 특징
- OfferPriceRQ 다중 용도: Pricing, MiniRules, Ancillary Repricing
- 재발행 Pricing 지원: OrderReshopRQ.ofPricing
- SOLD_OUT 별도 처리 없음: Book 단계에서 처리
- PassengerFare 간소화: 필수 정보만 포함
11.2 개선 가능 영역
1. 에러 코드 상세화
현황: 모든 에러를 PRICING_FAILED로 처리
제안: 에러 코드별 분류
offerPriceRS.checkError { code, message ->
when (code) {
"XXX" -> throw InternationalAdapterException(ErrorMessage.SOLD_OUT, code, message)
else -> throw InternationalAdapterException(ErrorMessage.PRICING_FAILED, code, message)
}.capture()
}12. 참고 자료
12.1 주요 클래스
SingaporeairClient.kt: SOAP 클라이언트 (215-248, 700-729)OfferPriceRQ.kt: Pricing 요청 DTOOfferPriceRS.kt: Pricing 응답 DTOOrderReshopRQ.kt: 재발행 Pricing 요청 DTOOrderReshopRS.kt: 재발행 Pricing 응답 DTO
12.2 주요 메소드
SingaporeairClient.pricing(): 예약 전 Pricing (215-248)SingaporeairClient.repricingWithReissue(): 재발행 Pricing (700-729)
12.3 관련 API
- OfferPriceRQ: 운임 재계산
- OfferPriceRS: 운임 재계산 응답
- OrderReshopRQ: 재발행 운임 재계산
- OrderReshopRS: 재발행 운임 재계산 응답
이 문서는 Triple Air International Adapter 프로젝트의 Singapore Airlines Pricing API 심층 분석 문서입니다.