Singapore Airlines 발권 API 심층 분석

1. API 엔드포인트 개요

1.1 발권 관련 엔드포인트

엔드포인트메서드API 타입기능위치
/internals/SINGAPOREAIR/ticketing/readyPOSTSOAP (NDC)발권 준비 (예약 조회)SingaporeairTicketingService.kt:27-30
/internals/SINGAPOREAIR/ticketingPOSTSOAP (NDC)발권 실행SingaporeairTicketingService.kt:32-62
/internals/SINGAPOREAIR/ticketing/reissuePOSTSOAP (NDC)재발행SingaporeairTicketingService.kt:64-95

2. 발권 준비 (Ticketing Ready)

2.1 전체 플로우

flowchart TD
    A[발권 준비 요청] --> B[예약 조회<br/>retrieve]
    B --> C[스케줄 정보<br/>반환]

    style B fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000
    style C fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

2.2 상세 분석

위치: SingaporeairTicketingService.kt:27-30

fun ready(pnr: String): Pair<List<Passenger>?, List<Schedule>> {
    val booking = singaporeairClient.retrieve(pnr)
    return null to booking.schedules!!
}

처리 내용:

  1. PNR로 예약 조회
  2. 스케줄 정보 반환
  3. 승객 정보는 null 반환 (NDC 특성)

Galileo와의 차이점:

항목Singapore Air (NDC)Galileo
발권 전 검증없음 (예약 조회만)validateBookingConditionForTicketing
승객 정보null 반환승객 정보 반환
스케줄 정보OrderViewRS에 포함UniversalRecordRetrieveRS에 포함

3. 발권 실행 (Issue)

3.1 전체 발권 플로우

flowchart TD
    A[발권 요청 수신] --> B[총 금액 계산<br/>cardPrice + cashPrice]
    B --> C[Payment 저장<br/>singaporeairClient.savePayment]

    C --> D{에러 발생?}
    D -->|Yes| E{NO_QUOTA<br/>에러?}
    E -->|Yes| F[Slack 알림<br/>sendNoQuota]
    E -->|No| G[에러 처리]
    F --> H[5초 대기 후<br/>비동기 취소]
    G --> H
    H --> I[예외 재발생]

    D -->|No| J[발권 완료<br/>승객 정보 반환]

    %% 타임아웃 처리
    C --> K{타임아웃?}
    K -->|Yes| L[Slack 알림<br/>sendTicketingTimeout]
    L --> I

    style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
    style C fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000
    style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style F fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style H fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000
    style J fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

3.2 단계별 상세 분석

Step 1: 총 금액 계산

위치: SingaporeairTicketingService.kt:36

val amount = passengerPrices.sumOf { it.cardPrice + it.cashPrice }

PassengerPrice 구조:

data class PassengerPrice(
    val type: PassengerType,    // ADT, CHD, INF
    val cardPrice: Long,        // 카드 결제 금액
    val cashPrice: Long,        // 현금 결제 금액
)

총 금액 계산:

  • 모든 승객의 cardPrice + cashPrice 합산
  • NDC는 승객별 분리 결제 지원

Step 2: Payment 저장 (OrderChangeRQ)

위치: SingaporeairTicketingService.kt:38-59

val booking = try {
    singaporeairClient.savePayment(
        pnr = pnr,
        amount = amount,
        timeoutCallback = {
            slackService.sendTicketingTimeout(
                supplier = Supplier.SINGAPOREAIR,
                pnr = pnr,
            )
        }
    )
} catch (e: Exception) {
    // NO_QUOTA 특별 처리
    if (e is ApiException && e.errorMessage == ErrorMessage.NO_QUOTA) {
        slackService.sendNoQuota(
            supplier = Supplier.SINGAPOREAIR,
            airline = "SQ",
            reason = e.message
        )
    }
    // 발권 실패 시 5초 대기 후 비동기 취소
    cancelAsync(pnr)
    throw e
}

타임아웃 콜백:

  • 발권 API 타임아웃 시 Slack 알림
  • 60초 타임아웃 (기본값)

에러 처리:

  1. NO_QUOTA 에러: 항공사 할당량 초과

    • Slack 알림 전송 (항공사별)
    • 비동기 취소 실행
  2. 기타 에러:

    • 비동기 취소 실행
    • 예외 재발생

Step 3: 비동기 취소 처리

위치: SingaporeairTicketingService.kt:97-111

private fun cancelAsync(pnr: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        delay(5000)  // 5초 대기
        try {
            singaporeairClient.cancel(pnr)
        } catch (e: Exception) {
            slackService.sendCancelFail(
                supplier = Supplier.SINGAPOREAIR,
                pnr = pnr,
                reason = e.message
            )
            throw e
        }
    }
}

비동기 취소 특징:

  • 5초 대기 후 취소 (시스템 안정화 대기)
  • 비동기 처리 (API 응답 지연 방지)
  • 취소 실패 시 Slack 알림

Galileo와의 차이점:

  • Galileo: 즉시 비동기 취소 (5초 대기 있음)
  • Singapore Air: 5초 대기 후 비동기 취소
  • 공통점: 취소 실패 시 Slack 알림

4. savePayment API 상세 분석

4.1 전체 플로우

위치: SingaporeairClient.kt:366-409

fun savePayment(
    pnr: String,
    amount: Long,
    timeoutCallback: () -> Unit,
): Booking {
    val singaporeairApiProperties = singaporeairProperties.getApiProperties()
 
    val request = OrderChangeRQ.ofPayment(
        pnr = pnr,
        amount = amount,
        iataNumber = singaporeairApiProperties.iataCode,
        agencyName = singaporeairApiProperties.agencyName
    )
 
    return singaporeairApiProperties.endpoint
        .post(request)
        .header(getHeaderMap(request))
        .requestBodyConvert(soapRequestBodyConverter(singaporeairApiProperties))
        .execute<OrderViewRS>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { orderViewRS ->
                orderViewRS.also {
                    it.checkError { code, message ->
                        if (message.contains("MAXIMUM TICKET LIMIT REACHED")) {
                            throw InternationalAdapterException(ErrorMessage.NO_QUOTA, code, message)
                        } else {
                            // TODO payment error code 정리 필요(ref: Code set Dictionary 18.1.pdf - 9321)
                            throw InternationalAdapterException(
                                ErrorMessage.TICKETING_FAILED,
                                pnr,
                                code,
                                message
                            ).capture()
                        }
                    }
                }.response!!.toBooking()
            },
            failure = { failure ->
                if (failure.isTimeout) {
                    timeoutCallback()  // Slack 알림
                }
                throw failure.handleSoapFaultException(ErrorMessage.TICKETING_FAILED, pnr)
            }
        )
}

4.2 에러 처리

NO_QUOTA 에러 처리

if (message.contains("MAXIMUM TICKET LIMIT REACHED")) {
    throw InternationalAdapterException(ErrorMessage.NO_QUOTA, code, message)
}

NO_QUOTA 특징:

  • 항공사 할당량 초과 시 발생
  • 메시지: “MAXIMUM TICKET LIMIT REACHED”
  • Slack 알림: 항공사별로 전송

타임아웃 처리

failure = { failure ->
    if (failure.isTimeout) {
        timeoutCallback()  // Slack 알림
    }
    throw failure.handleSoapFaultException(ErrorMessage.TICKETING_FAILED, pnr)
}

타임아웃 특징:

  • 60초 타임아웃 (기본값)
  • 타임아웃 시 Slack 알림 전송
  • 예외 재발생

5. Request/Response 구조 상세

5.1 OrderChangeRQ.ofPayment 구조

위치: infrastructure/request/OrderChangeRQ.kt

주요 필드:

data class OrderChangeRQ(
    val query: Query,
) : SingaporeairRequest {
    override val action = "http://www.iata.org/IATA/2015/00/2018.2/OrderChangeRQ"
 
    companion object {
        fun ofPayment(
            pnr: String,
            amount: Long,
            iataNumber: String,
            agencyName: String,
        ): OrderChangeRQ {
            return OrderChangeRQ(
                query = Query(
                    order = ChangeOrder.ofPayment(
                        pnr = pnr,
                        amount = amount,
                        iataNumber = iataNumber,
                        agencyName = agencyName
                    )
                )
            )
        }
    }
}

ChangeOrder.ofPayment 구조:

data class ChangeOrder(
    val actionType: ActionType,        // UPDATE
    val orderId: OrderId,              // PNR
    val payments: Payments?,           // 결제 정보
) {
    companion object {
        fun ofPayment(
            pnr: String,
            amount: Long,
            iataNumber: String,
            agencyName: String,
        ): ChangeOrder {
            return ChangeOrder(
                actionType = ActionType(value = "UPDATE"),
                orderId = OrderId(value = pnr),
                payments = Payments(
                    payment = listOf(
                        Payment(
                            paymentMethod = PaymentMethod(
                                otherPaymentMethod = OtherPaymentMethod(
                                    otherPaymentMethodID = OtherPaymentMethodID(value = "CASH"),
                                    otherPaymentMethodText = OtherPaymentMethodText(value = "CASH")
                                ),
                                cash = Cash(
                                    cashInd = CashInd(value = true),
                                    amount = Amount(
                                        currencyAmountValue = CurrencyAmountValue(
                                            currencyCode = CurrencyCode(value = "KRW"),
                                            value = Value(value = amount.toString())
                                        )
                                    )
                                )
                            ),
                            pointOfSale = PointOfSale(
                                country = Country(countryCode = "KR"),
                                agencyID = AgencyID(value = iataNumber),
                                agencyName = AgencyName(value = agencyName),
                            )
                        )
                    )
                )
            )
        }
    }
}

Payment 구조:

data class Payment(
    val paymentMethod: PaymentMethod,  // 결제 수단
    val pointOfSale: PointOfSale,      // 판매 지점
)

PaymentMethod 구조:

data class PaymentMethod(
    val otherPaymentMethod: OtherPaymentMethod?,  // CASH
    val cash: Cash?,                               // 현금 결제 정보
    val paymentCard: PaymentCard?,                 // 카드 결제 정보 (미사용)
)

Cash 구조:

data class Cash(
    val cashInd: CashInd,              // true
    val amount: Amount,                // 금액
)
 
data class Amount(
    val currencyAmountValue: CurrencyAmountValue,
)
 
data class CurrencyAmountValue(
    val currencyCode: CurrencyCode,    // KRW
    val value: Value,                  // 금액
)

5.2 OrderViewRS 구조 (발권 응답)

위치: infrastructure/response/OrderViewRS.kt

주요 필드:

data class OrderViewRS(
    val response: Response?,
    val errors: List<Error>?,
) {
    data class Response(
        val order: Order,
        val dataList: DataList,
        val warnings: List<Warning>?,
    )
}

Order 구조 (발권 후):

data class Order(
    val orderId: OrderId,              // PNR
    val orderItems: OrderItems,        // 주문 항목
    val ownerCode: OwnerCode,          // SQ
    val statusCode: StatusCode,        // TICKETED
    val totalPrice: TotalPrice,        // 총 가격
    val bookingRefs: BookingRefs?,     // 항공사 예약 번호
    val paymentInfo: List<PaymentInfo>?, // 결제 정보
    val ticketDocInfos: List<TicketDocInfo>?, // 티켓 정보
)

StatusCode 값:

의미
CONFIRMED예약 확정
TICKETED발권 완료
CANCELLED취소됨

TicketDocInfos 구조:

data class TicketDocInfos(
    val ticketDocInfo: List<TicketDocInfo>,
)
 
data class TicketDocInfo(
    val ticketDocNbr: TicketDocNbr,    // 티켓 번호 (13자리)
    val ticketDocTypeCode: TicketDocTypeCode, // 701 (E-Ticket), 702 (EMD)
    val paxRefId: PaxRefId,            // 승객 참조 ID
    val reportingTypeCode: ReportingTypeCode?, // RPT
)

TicketDocTypeCode 값:

코드의미
701E-Ticket (항공권)
702EMD (Electronic Miscellaneous Document, 부가서비스)

PaymentInfo 구조:

data class PaymentInfo(
    val paymentInfoId: PaymentInfoId, // 결제 ID
    val amount: Amount,                // 결제 금액
    val paymentMethod: PaymentMethod,  // 결제 수단
    val paymentStatusCode: PaymentStatusCode, // 결제 상태
    val paxRefIds: List<PaxRefId>?,    // 승객 참조 ID
)

6. 재발행 (Reissue)

6.1 전체 재발행 플로우

flowchart TD
    A[재발행 요청] --> B[원본 예약 조회<br/>retrieve]
    B --> C[FareItinerary<br/>조회]
    C --> D[Repricing<br/>repricingWithReissue]
    D --> E{가격 일치<br/>확인}
    E -->|불일치| F[RETICKETING_FAILED_BY_MISMATCH_PRICE]
    E -->|일치| G[재발행 실행<br/>reissue]
    G --> H[재발행 완료<br/>ReissueResult 반환]

    style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
    style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style E fill:#9FB4CE,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

6.2 단계별 상세 분석

Step 1: 원본 예약 조회

위치: SingaporeairTicketingService.kt:69

val originBooking = singaporeairClient.retrieve(pnr)

Step 2: FareItinerary 조회

위치: SingaporeairTicketingService.kt:70

val fareItinerary = singaporeairFlightSearchService.getFareItinerary(detailKey)

detailKey:

  • 재발행 검색 결과의 키
  • Redis에 캐싱된 FareItinerary 조회

Step 3: Repricing (재가격 계산)

위치: SingaporeairTicketingService.kt:73-76

val passengerFares = singaporeairClient.repricingWithReissue(
    booking = originBooking,
    fareItinerary = fareItinerary
).passengerFares

repricingWithReissue API: SingaporeairClient.kt:700-729

  • 엔드포인트: NDC API (OrderReshopRQ with Pricing)
  • 목적: 재발행 운임 재계산
  • 반환값: FareItinerary (passengerFares 포함)

Step 4: 가격 일치 확인

위치: SingaporeairTicketingService.kt:78-86

if (prepaidPrice != passengerFares.sumOf {
        (it.total + (it.carrierFee ?: 0)) * it.count
    }) {   //total = airPrice + tax
    throw InternationalAdapterException(
        ErrorMessage.RETICKETING_FAILED_BY_MISMATCH_PRICE,
        pnr,
        "price : $prepaidPrice , pricedTotal : ${passengerFares.sumOf { (it.total + (it.carrierFee ?: 0)) * it.count }}}"
    )
}

가격 계산:

총 가격 = Σ[(airPrice + tax + carrierFee) * count]

가격 불일치 시:

  • RETICKETING_FAILED_BY_MISMATCH_PRICE 예외 발생
  • 로그: 요청 가격 vs 재계산 가격

Step 5: 재발행 실행

위치: SingaporeairTicketingService.kt:88-92

val newBooking = singaporeairClient.reissue(
    booking = originBooking,
    price = prepaidPrice,
    fareItinerary = fareItinerary
)

reissue API: SingaporeairClient.kt:731-771

  • 엔드포인트: NDC API (OrderChangeRQ with Reissue)
  • 파라미터: 원본 예약, 가격, 새 FareItinerary
  • 반환값: 새 Booking

Step 6: 결과 반환

위치: SingaporeairTicketingService.kt:94

return ReissueResult(booking = newBooking, passengers = passengerFares)

ReissueResult 구조:

data class ReissueResult<B, P>(
    val booking: B,          // 새 Booking
    val passengers: List<P>, // 승객별 운임
)

7. reissue API 상세 분석

7.1 전체 플로우

위치: SingaporeairClient.kt:731-771

fun reissue(
    booking: Booking,
    price: Long,
    fareItinerary: FareItinerary,
): Booking {
    val singaporeairApiProperties = singaporeairProperties.getApiProperties()
 
    val request = OrderChangeRQ.ofReissue(
        booking = booking,
        price = price,
        fareItinerary = fareItinerary,
        iataNumber = singaporeairApiProperties.iataCode,
        agencyName = singaporeairApiProperties.agencyName
    )
 
    return singaporeairApiProperties.endpoint
        .post(request)
        .header(getHeaderMap(request))
        .requestBodyConvert(soapRequestBodyConverter(singaporeairApiProperties))
        .execute<OrderViewRS>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { orderViewRS ->
                orderViewRS.checkError { code, message ->
                    throw if (message.contains("TICKET IS NOT ELIGIBLE FOR EXCHANGE")) {
                        InternationalAdapterException(
                            ErrorMessage.TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE,
                            booking.pnr,
                            code,
                            message
                        )
                    } else {
                        InternationalAdapterException(ErrorMessage.RETICKETING_FAILED, booking.pnr, code, message)
                    }.capture()
                }
                orderViewRS.response!!.toReissueBooking(originBooking = booking)
            },
            failure = {
                throw it.exception
            }
        )
}

7.2 에러 처리

TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE 에러

if (message.contains("TICKET IS NOT ELIGIBLE FOR EXCHANGE")) {
    throw InternationalAdapterException(
        ErrorMessage.TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE,
        booking.pnr,
        code,
        message
    )
}

발생 원인:

  • 티켓이 재발행 불가능한 상태
  • 운임 규정상 교환 불가
  • 이미 사용된 티켓

7.3 OrderChangeRQ.ofReissue 구조

위치: infrastructure/request/OrderChangeRQ.kt

주요 필드:

companion object {
    fun ofReissue(
        booking: Booking,
        price: Long,
        fareItinerary: FareItinerary,
        iataNumber: String,
        agencyName: String,
    ): OrderChangeRQ {
        return OrderChangeRQ(
            query = Query(
                order = ChangeOrder.ofReissue(
                    booking = booking,
                    price = price,
                    fareItinerary = fareItinerary,
                    iataNumber = iataNumber,
                    agencyName = agencyName
                )
            )
        )
    }
}

ChangeOrder.ofReissue 구조:

data class ChangeOrder(
    val actionType: ActionType,        // REPLACE
    val orderId: OrderId,              // 원본 PNR
    val deleteOrderItems: List<DeleteOrderItem>?,  // 삭제할 항목
    val addOrderItems: List<AddOrderItem>?,        // 추가할 항목
    val payments: Payments?,           // 결제 정보
) {
    companion object {
        fun ofReissue(
            booking: Booking,
            price: Long,
            fareItinerary: FareItinerary,
            iataNumber: String,
            agencyName: String,
        ): ChangeOrder {
            return ChangeOrder(
                actionType = ActionType(value = "REPLACE"),
                orderId = OrderId(value = booking.pnr),
                deleteOrderItems = booking.schedules!!.map { schedule ->
                    DeleteOrderItem(
                        orderItemRefId = OrderItemRefId(value = schedule.orderItemId)
                    )
                },
                addOrderItems = listOf(
                    AddOrderItem.of(fareItinerary)
                ),
                payments = Payments.of(price, iataNumber, agencyName)
            )
        }
    }
}

재발행 로직:

  1. DeleteOrderItems: 기존 항공편 삭제
  2. AddOrderItems: 새 항공편 추가
  3. Payments: 차액 결제 정보

8. 에러 처리 및 재시도 메커니즘

8.1 에러 타입별 처리 매트릭스

에러 타입발생 시점처리 방법Slack 알림비동기 취소
NO_QUOTAsavePaymentInternationalAdapterExceptionYes (항공사별)Yes (5초 대기)
TICKETING_FAILEDsavePaymentInternationalAdapterExceptionNoYes (5초 대기)
TICKETING_TIMEOUTsavePaymentSoapFaultExceptionYesNo
RETICKETING_FAILED_BY_MISMATCH_PRICEReissueInternationalAdapterExceptionNoNo
TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGEReissueInternationalAdapterExceptionNoNo
RETICKETING_FAILEDReissueInternationalAdapterExceptionNoNo

8.2 Slack 알림 케이스

1. 발권 타임아웃

timeoutCallback = {
    slackService.sendTicketingTimeout(
        supplier = Supplier.SINGAPOREAIR,
        pnr = pnr,
    )
}

2. NO_QUOTA (할당량 초과)

if (e is ApiException && e.errorMessage == ErrorMessage.NO_QUOTA) {
    slackService.sendNoQuota(
        supplier = Supplier.SINGAPOREAIR,
        airline = "SQ",
        reason = e.message
    )
}

3. 취소 실패

slackService.sendCancelFail(
    supplier = Supplier.SINGAPOREAIR,
    pnr = pnr,
    reason = e.message
)

8.3 재시도 메커니즘

현재 상태: 재시도 로직 없음

비동기 취소만 지원:

  • 5초 대기 후 취소
  • 취소 실패 시 Slack 알림

9. GDS와의 차이점 비교

9.1 발권 플로우 비교

항목Singapore Air (NDC)GalileoAmadeusSabre
발권 전 검증없음validateBookingConditionForTicketing복잡한 상태 검증상태 검증
발권 APIOrderChangeRQ (Payment)AirTicketingDocIssuanceTicketingClient
Payment 타입CASH (고정)CASHCASHCASH
티켓 조회OrderViewRS에 포함별도 API별도 API별도 API
재발행OrderChangeRQ.ofReissueN/AN/AN/A
비동기 취소5초 대기 후5초 대기 후즉시즉시

9.2 에러 처리 비교

에러 시나리오Singapore AirGalileoAmadeusSabre
할당량 초과”MAXIMUM TICKET LIMIT REACHED” → NO_QUOTAN/A”QUOTA EXCEEDED""NO QUOTA”
타임아웃Slack 알림Slack 알림Slack 알림Slack 알림
재발행 불가”TICKET IS NOT ELIGIBLE FOR EXCHANGE”N/AN/AN/A
가격 불일치RETICKETING_FAILED_BY_MISMATCH_PRICEN/AN/AN/A

9.3 독특한 특징

Singapore Airlines NDC 고유 특징

  1. OrderChangeRQ 다중 용도:

    • Payment (발권)
    • Seat (좌석 선택)
    • Ancillary (부가서비스)
    • Divide (예약 분할)
    • Reissue (재발행)
  2. 통합 응답 구조:

    • OrderViewRS에 예약/티켓 정보 모두 포함
    • GDS처럼 별도 티켓 조회 API 불필요
  3. 재발행 지원:

    • NDC 표준의 고유 기능
    • GDS는 재발행 API 없음
  4. NO_QUOTA 에러 처리:

    • 메시지 기반 판별 (“MAXIMUM TICKET LIMIT REACHED”)
    • 항공사별 Slack 알림
  5. 발권 전 검증 없음:

    • GDS는 복잡한 상태 검증 수행
    • NDC는 예약 조회만

10. 성능 최적화

10.1 비동기 처리

위치: SingaporeairTicketingService.kt:97-111

private fun cancelAsync(pnr: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        delay(5000)
        try {
            singaporeairClient.cancel(pnr)
        } catch (e: Exception) {
            slackService.sendCancelFail(...)
            throw e
        }
    }
}

비동기 처리 대상:

  • 발권 실패 시 예약 취소

성능 향상:

  • API 응답 지연 방지
  • 5초 대기 (시스템 안정화)

10.2 타임아웃 설정

// ClientSupport.kt
defaultTimeout = 60000  // 60초

타임아웃 특징:

  • 발권 API: 60초 (기본값)
  • 타임아웃 시 Slack 알림

11. 주요 발견사항

11.1 Singapore Airlines NDC 발권 시스템의 특징

  1. OrderChangeRQ 기반: NDC 표준의 OrderChange 사용
  2. 통합 응답: OrderViewRS에 티켓 정보 포함
  3. 재발행 지원: NDC 고유 기능
  4. NO_QUOTA 처리: 메시지 기반 판별
  5. 발권 전 검증 없음: 예약 조회만 수행

11.2 개선 가능 영역

1. Payment Error Code 정리

현황: TODO 주석만 존재

// TODO payment error code 정리 필요(ref: Code set Dictionary 18.1.pdf - 9321)

제안: Code set Dictionary 참고하여 에러 코드 문서화

2. 재발행 가격 불일치 로깅

현황: 예외 메시지에만 포함

"price : $prepaidPrice , pricedTotal : ${...}"

제안: 로깅 추가

logger.warn(
    "[SINGAPOREAIR] Reissue price mismatch, PNR: $pnr, " +
    "requested: $prepaidPrice, calculated: ${passengerFares.sumOf { ... }}"
)

3. 비동기 취소 로깅

현황: 로그 없음

제안: 로깅 추가

private fun cancelAsync(pnr: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        logger.info("[SINGAPOREAIR] Cancelling PNR after ticketing failure: $pnr (5s delay)")
        delay(5000)
        try {
            singaporeairClient.cancel(pnr)
            logger.info("[SINGAPOREAIR] PNR cancelled successfully: $pnr")
        } catch (e: Exception) {
            logger.error("[SINGAPOREAIR] Failed to cancel PNR: $pnr", e)
            slackService.sendCancelFail(...)
            throw e
        }
    }
}

12. 참고 자료

12.1 주요 클래스

  • SingaporeairTicketingService.kt: 발권 서비스 (27-111)
  • SingaporeairClient.kt: SOAP 클라이언트 (366-409, 700-771)
  • OrderChangeRQ.kt: 변경 요청 모델 (Payment, Reissue)
  • OrderViewRS.kt: 응답 모델

12.2 주요 메소드

  • SingaporeairTicketingService.ready(): 발권 준비 (27-30)
  • SingaporeairTicketingService.issue(): 발권 실행 (32-62)
  • SingaporeairTicketingService.reissue(): 재발행 (64-95)
  • SingaporeairClient.savePayment(): SOAP 발권 API (366-409)
  • SingaporeairClient.reissue(): SOAP 재발행 API (731-771)

12.3 관련 API

  • OrderChangeRQ: 주문 변경 (Payment, Reissue)
  • OrderViewRS: 주문 조회 응답 (티켓 정보 포함)
  • OrderReshopRQ: 재발행 검색/Pricing

이 문서는 Triple Air International Adapter 프로젝트의 Singapore Airlines 발권 API 심층 분석 문서입니다.