Singapore Airlines Pricing API 심층 분석

1. API 엔드포인트 개요

1.1 Pricing 관련 API

기능API 타입호출 위치목적
예약 전 PricingOfferPriceRQSingaporeairBookingService.kt:44-49예약 전 운임 재계산
재발행 PricingOrderReshopRQSingaporeairClient.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. Pricingpricing()List<PassengerFare>운임 재계산
2. MiniRulesgetMiniRules()List<MiniRule>운임 규정 조회
3. Ancillary RepricingrepricingWithAncillary()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_FAILEDPricingInternationalAdapterException (Sentry 캡처)
PRICING_FAILEDRepricingInternationalAdapterException (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)GalileoAmadeusSabre
Pricing APIOfferPriceRQAirPriceRQFarePricePNRPriceQuote
재발행 PricingOrderReshopRQN/AN/AN/A
SOLD_OUT 처리Book 단계Pricing 단계Pricing 단계Pricing 단계
PassengerFare 구조type, count, airPrice, tax, total, carrierFee유사유사유사
다중 용도Pricing, MiniRules, AncillaryPricing만Pricing만Pricing만

11. 주요 발견사항

11.1 Singapore Airlines NDC Pricing 특징

  1. OfferPriceRQ 다중 용도: Pricing, MiniRules, Ancillary Repricing
  2. 재발행 Pricing 지원: OrderReshopRQ.ofPricing
  3. SOLD_OUT 별도 처리 없음: Book 단계에서 처리
  4. 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 요청 DTO
  • OfferPriceRS.kt: Pricing 응답 DTO
  • OrderReshopRQ.kt: 재발행 Pricing 요청 DTO
  • OrderReshopRS.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 심층 분석 문서입니다.