# FKTG Customer OMS API 연동 가이드

문서 버전: 2026-11-05. 고객사별 권한·서비스 설정과 실제 운송사 수락 여부는 별도로 확인하세요. 공개 개발자 문서: `https://www.fktg.co.kr/developers`.

## API 구성

- **Rate API:** 운송 서비스와 예상 운임 조회.
- **Ship API:** 발송 생성·조회·수정·취소, FKTG/에이전트 운송장번호 조회, FKTG house label PDF 다운로드.
- **Tracking API:** 고객사 발송물의 저장된 배송 상태와 이력 조회.

인증은 세 API의 공통 기능입니다. Tracking의 기존 경로와 `shipments.read` 권한은 그대로 사용합니다.

## 환경과 인증

운영 base URL은 `https://www.fktg.co.kr/api/open/v1`입니다. 별도 샌드박스는 제공되지 않습니다. 운영을 테스트 환경으로 사용하지 마세요. 합성 데이터 E2E는 격리된 로컬 DB와 로컬 서버에서 실행합니다.

`POST /auth/token`에 발급받은 `client_id`, `client_secret`을 JSON으로 보냅니다. 반환된 `access_token`은 opaque 문자열입니다. JWT 구조나 내부 값을 해석하지 마세요. `token_type`은 `Bearer`, `expires_in`은 초 단위이며 `scope`는 해당 credential에 실제 허용된 공백 구분 권한입니다. 모든 credential이 모든 권한을 갖는 것은 아닙니다. 이후 `Authorization: Bearer {access_token}`을 사용합니다. Secret/token은 고객사 백엔드의 비밀 저장소에만 보관하고 브라우저에 전달하지 않습니다.

| 기능 | Method / 경로 | 필요한 scope |
|---|---|---|
| 토큰 | `POST /auth/token` | Client ID/Secret |
| 견적 | `POST /rates/quotes` | `rates.read` |
| 생성 | `POST /shipments` | `shipments.write` |
| 상세 | `GET /shipments/{shipmentId}` | `shipments.read` |
| 외부 참조 | `GET /shipments/external/{externalShipmentRef}` | `shipments.read` |
| FKTG AWB | `GET /shipments/fktg-awb/{fktgAwb}` | `shipments.read` |
| 수정 | `PATCH /shipments/{shipmentId}` | `shipments.write` |
| 취소 | `POST /shipments/{shipmentId}/cancel` | `shipments.write` |
| 저장된 배송 상태 | `GET /shipments/{shipmentId}/tracking` | `shipments.read` |
| AWB 정보 | `GET /shipments/{shipmentId}/awb` | `shipments.read` |
| FKTG house label PDF | `GET /shipments/{shipmentId}/label` | `shipments.read` |

## 공개 식별자와 순서

인증 → 견적 → 생성 → 상세 → 허용 상태에서 PATCH → house label → tracking → 허용 상태에서 취소 순서로 연동합니다. 생성 응답의 `shipment_id`를 그대로 저장해 모든 `{shipmentId}` 경로에 사용합니다. 형식은 `shp_` + 32자리 소문자 16진수이며 내부 DB UUID와 별개인 안정적인 opaque ID입니다. 고객사가 직접 만들거나 내부 UUID로 대체하지 않습니다.

상세, 외부 참조 조회, FKTG AWB 조회는 같은 공개 `shipment_id`를 반환합니다. 기존 발송물도 migration backfill 이후 외부 참조 또는 12자리 숫자 문자열 `fktg_awb`로 공개 ID를 얻습니다. 모든 조회는 인증한 회사 범위이며 다른 회사의 ID/참조/AWB는 404입니다. 공개 API는 내부 UUID 경로를 지원하지 않습니다. 관리자 내부 ID 계약은 유지됩니다.

`fktg_awb`는 FKTG 관리 번호이며 `carrier_awb`와 다릅니다. 문자열로 보관해 선행 0을 보존하세요. house label 다운로드는 유료 운송사 라벨 발행이나 운송사 주문 수락을 뜻하지 않습니다.

## Rate API: 견적

```json
{
  "weight": 1.2,
  "item_count": 1,
  "incoterms": "DAP",
  "dimensions": { "length": 25, "width": 15, "height": 10 },
  "recipient": { "city": "New York", "state": "NY", "country": "US", "postal_code": "10001" }
}
```

`weight`와 `dimensions`, 목적지 city/country/postal_code가 필요합니다. 멀티박스는 `packages[]`를 추가합니다. EU27 견적에는 1~1000 정수 `item_count`가 필수입니다. 수량 합계를 임의로 넣지 말고 실제 신고 품목 수를 반영하세요. Yun은 동일 HS6+영문 품명+원산지를 한 신고 Item으로 묶으며 기준 필드가 없으면 임의 합치지 않습니다.

견적 공개 필드는 `service_code`, `carrier_id`, `carrier_code`, `carrier_name`, `service_name`, `currency`, `total_cost`, `charged_weight`, `volume_weight_divisor` 및 선택 `requires_carrier_confirmation`입니다. `total_cost`는 고객용 견적 금액입니다. 원가·마진·할인 세부·credential은 반환하지 않습니다. `requires_carrier_confirmation: true`이면 고객 화면에 운송사 수락 확인이 필요함을 표시하세요. 견적 성공은 운송사 acceptance가 아닙니다. `unavailable_quotes[].reason`은 비밀정보를 제거한 일반 메시지입니다.

Rate 응답의 `quotes`를 비교해 서비스 하나를 선택하고 예상 운임을 확인한 뒤, 해당 `service_code`로 Ship 생성을 한 번 호출합니다. 운임을 비교하려고 서비스별 발송물을 만들고 나머지를 취소하지 마세요. Rate 조회는 발송물·FKTG AWB·운송사 라벨을 생성하지 않으며 가격을 예약하지 않습니다. 신고 중량·규격에 따른 예상 운임이므로 창고 실측이나 추가 비용에 따라 최종 청구액이 달라질 수 있습니다.

`service_code`는 `FKTG-SVC-` 뒤에 대문자 16진수 32자리가 붙는 FKTG 고유 식별자입니다. 반환값을 그대로 저장하고 운송사 코드나 서비스명으로 직접 만들지 마세요. 생성에는 이 코드만으로 서비스를 지정할 수 있습니다. 기존 `carrier` + `service_name` 요청도 계속 지원합니다. 코드와 운송사·서비스명을 함께 보낼 때는 같은 서비스를 가리켜야 하며, 충돌하면 422 `SERVICE_SELECTION_CONFLICT`입니다. 잘못된 코드 형식은 400, 없거나 비활성인 Ship 코드는 422 `CARRIER_SERVICE_NOT_ACTIVE`입니다. 기존 이름이 여러 서비스와 일치하면 422 `CARRIER_SERVICE_AMBIGUOUS`이므로 Rate에서 코드를 선택하세요.

견적에 `service_code`를 추가하면 해당 서비스만 다시 조회합니다. 인증한 회사·목적지·포장·운송 조건에서 선택할 수 없으면 422 `CARRIER_SERVICE_NOT_CUSTOMER_SELECTABLE`이며 다른 서비스로 대체하지 않습니다. `unavailable_quotes`의 코드도 서비스를 식별하지만 견적 성공을 뜻하지 않습니다. `incoterms` 생략 시 견적은 DAP 기준이며 DDP-only 서비스를 보려면 DDP로 요청합니다.

## Ship API: 입력값 기준 운임 저장

Ship 생성 시 서버가 선택한 서비스, 포장물별 중량·규격, 목적지, incoterms, 신고 Item 수와 고객사 요율로 운임을 계산합니다. 발송물과 운임 계산 기록은 함께 저장됩니다. 기존 요청에 금액이나 견적 토큰을 추가할 필요가 없으며, 고객이 보낸 `total_cost`를 청구액으로 사용하지 않습니다. 이 내부 계산에는 별도의 `rates.read` 권한이 필요하지 않습니다.

생성 응답의 `estimated_rate`에는 `currency`, `total_cost`, `charged_weight`, `requires_carrier_confirmation`, `dimensions_complete`, `calculated_at`이 담깁니다. 상세·외부 참조·FKTG AWB 조회와 PATCH 응답에서도 마지막으로 저장한 신고값 기준 예상 운임을 확인합니다. 조회 자체는 운임을 다시 계산하지 않습니다. 과거에 계산 기록 없이 등록된 발송물은 `null`입니다.

서비스·중량·규격·목적지·incoterms·신고 품목 등 운임에 영향을 주는 값을 PATCH하면 새 운임도 함께 저장합니다. 계산에 실패하면 `422 RATE_UNAVAILABLE`이며 생성하거나 변경하지 않습니다. 변경 중 다른 수정·입고 작업이 먼저 반영되면 `409`이므로 재조회 후 판단하세요. 메모만 수정할 때는 기존 운임을 유지합니다.

규격을 생략하던 기존 Ship 요청도 지원합니다. 이 경우 임의 규격을 채우지 않으며 `dimensions_complete: false`로 반환합니다. 부피무게가 빠질 수 있으므로 가능한 모든 규격을 보내세요. 예상 운임은 실측·추가요금에 따라 달라질 수 있으며 결제·예치금 차감·출고 승인을 뜻하지 않습니다. 가격 비교에는 먼저 Rate를 사용하고, 등록 후에는 Ship이 실제 저장한 `estimated_rate`를 고객에게 보여주세요.

Yun은 한 발송물에 포장물 한 개만 지원합니다. 기존에는 다중 포장물이 Ship 등록을 통과해도 운임 조회·실제 라벨 발행에서 거절됐습니다. 이제 등록·운임 관련 수정 단계에서 `422 CARRIER_MULTI_PIECE_UNSUPPORTED`로 거절합니다. 포장물별로 각각 등록하거나 다중 포장물을 지원하는 서비스의 Rate를 조회하세요. 서버가 임의로 주문을 나누거나 합산 운임을 만들지 않습니다.

## Ship API: 생성 예제

```json
{
  "external_order_id": "SYNTHETIC-20261018-1",
  "external_shipment_ref": "SYNTHETIC-20261018-1-P1",
  "service_code": "FKTG-SVC-0123456789ABCDEF0123456789ABCDEF",
  "incoterms": "DAP",
  "export_reason": "SALE",
  "sender": { "company_name": "Example Shop", "name": "테스트 발송인", "phone": "01000000000", "address_line1": "Synthetic Seoul address" },
  "recipient": { "name": "Test Recipient", "phone": "+490000000000", "address_line1": "Synthetic Strasse 1", "address_line2": "Test floor", "address_line3": "Test room", "city": "Berlin", "postal_code": "10115", "country": "DE" },
  "packages": [{ "package_index": 1, "weight": 1.2, "length": 25, "width": 15, "height": 10 }],
  "items": [{ "sku": "TEST-SKU-1", "name_kr": "테스트 상품", "name_en": "Test product", "quantity": 1, "unit_price": 10, "invoice_currency": "EUR", "origin_country": "KR", "merchantProductId": "SHOP-TEST-1", "nonStandardManufacturerProductId": "MODEL-TEST-1", "standardManufacturerProductId": "0012345678905" }]
}
```

성공 시 HTTP 201과 `success`, `shipment_id`, `service_code`, `fktg_awb`, 외부 참조, `status`, `message`를 반환합니다. 조건에 따라 `warnings`가 추가됩니다. 예시는 합성 값입니다. 서비스 코드는 실제 Rate 응답의 선택값으로 바꾸고, 상품 식별번호도 실제 값을 사용하세요.

`sender`, `recipient`, `packages` 최소 1개, `items` 최소 1개가 필요합니다. 품목의 `name_kr`, `name_en`, 양의 정수 `quantity`, 0 이상 `unit_price`를 보냅니다. `invoice_currency`는 USD/KRW/JPY/CNY/GBP/EUR이며 생략 시 USD입니다. 우편번호와 PID는 문자열로 보냅니다. `hs_code`와 원산지는 선택이지만 실제 통관 시 보완이 필요할 수 있습니다.

`origin_country`와 `origin_country_code`는 같은 ISO alpha-2 원산지의 별칭입니다. 비어 있지 않은 값이 양쪽에 있으면 정규화 후 같아야 하며 충돌은 거절됩니다. 하나만 보내는 것을 권장합니다. 상세에서는 저장된 원산지를 두 별칭으로 제공합니다.

## 포장 유형과 팔레트 적재 수량

`packages[].declared_packaging_type`은 발송자가 신고한 최종 포장 단위입니다. 기존 일반 박스 요청에는 새 필드를 추가할 필요가 없습니다.

| 입력값 | 실제 포장 |
|---|---|
| `BOX` | 종이 박스 등 고객 포장. 운송사의 MY PACKAGE에 해당합니다. |
| `CRATE` | 목재 상자 |
| `PALLET` | 팔레트 |
| `SKID` | 스키드 |

MY PACKAGE는 설명용 명칭입니다. API에는 `MY_PACKAGE`가 아닌 `BOX`를 보냅니다.

- **생성:** 필드를 생략하면 `BOX`로 저장합니다. `CRATE`·`PALLET`·`SKID`는 실제 포장에 맞게 명시합니다.
- **견적:** 단일 포장물은 최상위 `declared_packaging_type`, 여러 포장물은 `packages[]`의 같은 필드를 사용합니다. `packages`가 있으면 배열의 값이 우선하며 각 행에서 생략한 값은 `BOX`입니다. 최상위 값을 배열에 자동 복사하지 않습니다.
- **수정:** `packages`는 전체 교체입니다. 같은 `package_index`의 신고 유형을 생략하면 저장값을 유지하고, 새 포장물에서 생략하면 `BOX`를 사용합니다. 명시한 `null`은 기존 신고값을 지웁니다.
- **조회:** `declared_packaging_type`은 신고값, `packaging_type`은 창고 확정값입니다. 두 값은 별개이며 고객은 창고 확정값을 설정할 수 없습니다. 명시한 `null`이나 기존 미신고 값은 조회 시 `null`일 수 있습니다.

`declared_pallet_piece_count`는 각 팔레트·스키드에 실린 개별 포장물 수이며 팔레트 개수나 상품 수량이 아닙니다. `PALLET`·`SKID`와 UPS Worldwide Express Freight에는 각 포장물당 1~99,999의 정수가 필요하고 전체 합계는 99,999 이하여야 합니다. 자동으로 1이나 상품 수량을 넣지 않습니다. PATCH에서 생략하면 기존 수량을 유지하며, 수량이 필요하지 않은 포장·서비스로 바꿀 때 `null`로 지울 수 있습니다. 조회의 `pallet_piece_count`는 창고 확정값입니다.

UPS의 `BOX`·`CRATE`는 Worldwide Express Freight를 사용할 수 없고, `PALLET`·`SKID`는 일반 소포 서비스를 사용할 수 없습니다. 한 발송물에 두 그룹을 혼합하지 마세요. 서비스 불일치는 검증 단계에 따라 HTTP 422 `CARRIER_SERVICE_NOT_CUSTOMER_SELECTABLE` 또는 `INVALID_PACKAGING_SERVICE`로 거절합니다. 기존 일반 박스 요청의 호환성은 Freight까지 모든 요청이 그대로 수락된다는 뜻이 아닙니다. Freight는 실제 포장 유형과 적재 수량을 확인해야 하며, 신고 유형이 없는 기존 UPS Freight 발송물도 수량이 필요합니다. 견적과 접수 가능 여부는 운송사 응답으로 확인합니다.

## 포장 유형별 요청 예제

앞의 견적·생성 예제는 포장 유형을 생략해 `BOX`를 사용합니다. `BOX`를 명시해도 같은 포장으로 처리합니다. 아래 수량은 합성 값이며 실제 포장 수량으로 바꿔야 합니다. OpenAPI와 Postman에는 네 유형의 견적·생성 요청이 모두 있습니다.

### 목재 상자 견적

```json
{
  "weight": 10,
  "incoterms": "DAP",
  "dimensions": { "length": 50, "width": 40, "height": 30 },
  "recipient": { "city": "New York", "state": "NY", "country": "US", "postal_code": "10001" },
  "declared_packaging_type": "CRATE"
}
```

### 팔레트 견적(개별 포장물 12개 적재)

```json
{
  "weight": 80,
  "incoterms": "DAP",
  "dimensions": { "length": 120, "width": 100, "height": 80 },
  "recipient": { "city": "New York", "state": "NY", "country": "US", "postal_code": "10001" },
  "packages": [{ "package_index": 1, "weight": 80, "length": 120, "width": 100, "height": 80, "declared_packaging_type": "PALLET", "declared_pallet_piece_count": 12 }]
}
```

### 스키드 생성(개별 포장물 12개, 상품 24개)

```json
{
  "external_order_id": "SYNTHETIC-SKID-1",
  "external_shipment_ref": "SYNTHETIC-SKID-1-P1",
  "carrier": "UPS",
  "service_name": "Worldwide Express Freight",
  "incoterms": "DAP",
  "export_reason": "SALE",
  "sender": { "company_name": "Example Shop", "name": "Test Sender", "phone": "01000000000", "address_line1": "Synthetic Seoul address" },
  "recipient": { "name": "Test Recipient", "phone": "+10000000000", "address_line1": "Synthetic address", "city": "New York", "state": "NY", "postal_code": "10001", "country": "US" },
  "packages": [{ "package_index": 1, "weight": 80, "length": 120, "width": 100, "height": 80, "declared_packaging_type": "SKID", "declared_pallet_piece_count": 12 }],
  "items": [{ "sku": "TEST-SKU-1", "name_kr": "테스트 상품", "name_en": "Test product", "quantity": 24, "unit_price": 10, "invoice_currency": "USD", "origin_country": "KR" }]
}
```

위 스키드 예제는 기존 `carrier` + `service_name` 형식입니다. 신규 연동은 해당 포장 조건으로 받은 견적의 `service_code`를 사용합니다. 위 UPS 서비스는 요청 형식을 설명하는 예제이며 계정별 사용 가능 여부를 보증하지 않습니다.

상세·외부 참조·FKTG AWB 조회와 PATCH 응답에서 선택한 `service_code`를 확인할 수 있습니다. 서비스 연결이 없는 과거 발송물은 `null`입니다. 허용 상태에서 PATCH에 새 `service_code`를 보내 서비스를 바꿀 수 있으며, 생략한 서비스 선택 필드는 기존 처리 방식을 유지합니다. 변경 후 목적지·포장·incoterms·PID와 입고·라벨 상태를 다시 검사합니다. 코드가 유지되어도 서비스 활성 여부와 운임은 바뀔 수 있으므로 발송 전 견적을 다시 확인하세요.

## EU PID 읽기와 수정

EU27 목적지는 `export_reason`이 필요합니다. 현재 소프트웨어의 B2C 판정(수취인 회사명 없음) + SALE에는 품목별 `merchantProductId`(M-PID), `nonStandardManufacturerProductId`(NS-PID)가 필수입니다. 회사명 기반 분류는 법적 B2B/B2C 판정이 아닙니다. GB/NO/CH는 EU27에 포함되지 않습니다. 임의 회사명·GIFT·가짜 NS-PID로 검증을 우회하지 않습니다.

FKTG 입력 기준에서 M-PID와 NS-PID는 발송자가 상품을 식별하기 위해 정하는 코드이며 같은 값을 사용할 수 있습니다. 아마존 상품은 ASIN 또는 FNSKU, 다른 플랫폼이나 자사몰 상품은 해당 플랫폼의 상품관리코드(SKU)를 입력합니다. 두 필드는 각각 입력해야 하며 하나를 생략하면 자동 복사되지 않습니다. `standardManufacturerProductId`(S-PID)는 제조자나 브랜드가 국제규격에 맞춰 부여한 바코드(GTIN·EAN·UPC·ISBN 등)로, 있는 경우에만 입력합니다.

선행 0은 유지합니다. 공백 S-PID는 미입력으로 정규화되어 readback은 null입니다. 개별 식별자는 2~70자, 전체 필드는 최대 200자의 printable ASCII이며 쉼표 구분 다중 식별자를 지원합니다. 같은 입력란 안의 중복, NA/N/A/NULL/NONE/UNKNOWN 등 placeholder 및 all-zero/all-nine는 거절됩니다. M-PID와 NS-PID 사이의 동일 값은 허용됩니다. `recipient.tax_id`, `sender_ioss`는 PID가 아닙니다. FedEx transport-only `NA`는 내부 운송사 변환이며 고객의 NA 입력을 허용한다는 뜻이 아닙니다. 운송사별 cutoff와 상품/금액 정책은 기존 정책 그대로 적용됩니다.

GET은 `export_reason`과 품목별 camelCase PID 3개를 allowlist로 반환합니다. 저장된 품목 identity와 정확히 결합된 snapshot만 사용하며 불일치·중복 snapshot은 PID를 추정하지 않습니다. 내부 품목 UUID나 external_payload 전체는 반환하지 않습니다.

PATCH는 최상위·sender·recipient의 제공 필드만 수정합니다. **items 또는 packages를 제공하면 전체 배열 교체**입니다. 기존 SKU/배열 순서로 품목을 추정 매칭하지 않습니다. 모든 남길 품목과 PID를 함께 보내세요. items를 생략하면 기존 품목과 결합을 유지합니다. export_reason만 변경할 수도 있으나 변경 후 EU/PID 정책을 충족해야 합니다.

```json
{
  "export_reason": "SALE",
  "items": [{ "sku": "TEST-SKU-1", "name_kr": "테스트 상품", "name_en": "Test product", "quantity": 2, "unit_price": 10, "invoice_currency": "EUR", "merchantProductId": "SHOP-TEST-1", "nonStandardManufacturerProductId": "MODEL-TEST-1", "standardManufacturerProductId": "0012345678905" }]
}
```

수정은 DB 트랜잭션에서 잠금 후 상태 재확인, 품목 교체, 새 identity에 PID 결합, 라벨 무효화를 함께 처리합니다. 입고스캔·운송사 라벨 발행 등 수정 불가 상태에서는 거절됩니다. 생성/수정 성공이 실제 Yun/FedEx/UPS 전송 수락을 보증하지 않습니다.

## 중복·취소·재시도

중복 범위는 **company_id + external_shipment_ref**입니다. 같은 회사의 서로 다른 credential도 이 범위를 공유합니다. 중복은 409 `DUPLICATE_EXTERNAL_SHIPMENT_REF`입니다. `external_order_id`는 주문 묶음이고 분할출고마다 다른 external_shipment_ref를 사용합니다. Idempotency-Key 헤더 계약은 없습니다.

생성 timeout/5xx 이후 새 참조로 재생성하기 전에 같은 외부 참조로 조회하여 대사합니다. 중복 409도 같은 조회로 기존 발송물을 확인합니다. 생성 parent/items/packages/history는 원자적입니다. 취소와 history, PATCH 상태 검증도 DB 트랜잭션으로 처리합니다.

수정·취소는 입고 전이라도 운송사 라벨/활성 작업/상태 잠금 때문에 거절될 수 있습니다. 409 업무 code를 확인하고 운영팀에 문의합니다. API 취소가 운송사 void/환불/결제 취소를 뜻하지 않습니다. 이미 발행된 운송사 라벨의 void/reconciliation은 기존 보호 절차가 필요합니다.

## 운송 조건

| 운송사 | 현재 조건 |
|---|---|
| FedEx | DAP / DDP |
| UPS | DAP, DDP 모두 지원 |
| Yun Express | BR은 DAP, 그 외 DDP |
| SF Express | US는 DDP. 유연 서비스는 그 외 DAP/DDP. E-Commerce Express CD의 DAP는 TH/TW/HK/CN/SG/VN/MO/PH에 한함 |

목적지·서비스 활성 상태·회사별 설정도 검사합니다. Quote는 요청한 incoterms로 검증합니다. create의 기존 호환 정책은 Yun non-BR 및 SF-CD 일부 목적지에서 DAP를 DDP로 보정하며 `INCOTERMS_NORMALIZED_TO_DDP` warning을 반환합니다. DAP/DDP는 부담 주체를 바꾸므로 고객사 화면에서 사전 동의를 받고 DDP를 명시적으로 보내세요. warning을 무시하거나 두 조건의 가격이 같다고 가정하지 않습니다. PATCH는 부적합한 DAP를 422로 거절하며 자동 보정하지 않습니다.

Yun EU 요금은 2026-11-01부터 €3 임시 관세 + €2 처리수수료, 환율 1,800원으로 합계 9,000원입니다. VAT 대납 수수료는 별도 후불입니다. **운영 견적·관리자 실측·청구 재계산은 2026-11-01 00:00 KST부터 5,400원 + 3,600원 = 9,000원 × item_count를 적용하며, 기존 5,400원에 9,000원을 추가하지 않습니다.** 그 전에는 5,400원을 유지합니다. 금액 전환 코드는 운영에 반영됐으며 위 시행일부터 적용됩니다. 동일 HS 앞 6자리 + 앞뒤 공백 제거 영문 품명(대소문자 유지) + 대문자 원산지를 하나의 신고 Item으로 묶으며 quantity는 합산 기준이 아닙니다. €150/USD155 관련 기존 Yun 수락 제한을 완화하지 않습니다. 이 상업 견적 정책을 모든 거래에 대한 법정 관세 확정으로 해석하지 않으며 계정별 실제 청구·VAT/IOSS·법적 적용 범위는 운영 확인이 필요합니다. FedEx/UPS에는 이 선수취를 적용하지 않습니다.

## Ship API: 운송장번호와 house label PDF

`GET /shipments/{shipmentId}/awb`는 `shipments.read` 권한으로 조회합니다. `fktg_awb`는 FKTG 관리 번호, `carrier_awb`는 고객 화면의 Agent AWB(에이전트/운송사 운송장번호)입니다. `carrier_awb`가 아직 저장되지 않았다면 `null`입니다. 번호 발급 후 같은 공개 ID로 다시 조회하고, 선행 0을 보존하도록 문자열로 저장하세요. 이 GET 요청은 운송사 라벨을 발급하지 않습니다. 합성 응답 예제는 OpenAPI의 `assigned`와 `awaiting_assignment`를 참고하세요.

`GET /shipments/{shipmentId}/label`은 `application/pdf` 바이너리를 반환하며 여러 패키지는 페이지별로 포함됩니다. 성공 응답을 JSON으로 파싱하지 않습니다. 한글·일본어·중국어·영문을 지원합니다. 고객 이름·주소를 삭제하거나 음역하지 않습니다. 운송사 라벨 발행과 별개의 FKTG 분류/입고용 라벨입니다.

## Tracking API: 배송 상태

Tracking은 저장된 상태와 고객 공개 가능한 이력을 반환합니다. 매 요청마다 운송사를 실시간 조회하지 않습니다. 보장된 freshness/SLA나 전체 API quota는 확인되지 않았습니다. 고객사 백엔드에서 겹치는 polling을 합치고 지수 backoff와 jitter를 사용합니다. 웹훅 전송·재고 CRUD·피킹 API는 이 계약에 포함되지 않습니다.

## 오류와 관측

오류는 `success: false`, `error.code`, `error.message`, **항상 존재하는 `error.retryable`**, 선택 `guide`/`field`/`details`를 반환합니다. 서버가 생성한 `error.correlation_id`와 `x-correlation-id`를 지원 문의에 사용하세요. Secret/token/주소/전화번호/통관번호와 SQL·테이블·컬럼·provider 원문은 응답과 로그에 남기지 않습니다.

| HTTP | code (고정 enum) | retryable | 처리 |
|---|---|---|---|
| 400 | INVALID_REQUEST | false | JSON/입력 수정 |
| 401 | UNAUTHORIZED_CLIENT | false | 토큰 만료/인증 확인 |
| 403 | FORBIDDEN | false | credential 상태·회사 승인·scope 확인 |
| 404 | SHIPMENT_NOT_FOUND | false | 회사 범위와 식별자 확인 |
| 409 | CONFLICT | false | 구체 업무 code로 상태/중복 대사 |
| 422 | VALIDATION_ERROR | false | 운송·EU/PID 등 구체 업무 code 확인 |
| 429 | RATE_LIMITED | true | `Retry-After` 초만큼 대기 후 재시도 |
| 503 | AWB_GENERATION_FAILED | true | `Retry-After`(5초)만큼 대기 후 **같은 payload** 재시도. 요청 자체는 유효합니다 |
| 503 | SERVICE_UNAVAILABLE | true | 일시적 DB/연결 장애. `Retry-After`(5초) 준수 + 지수 backoff·jitter로 같은 payload 재시도 |
| 500 | SHIPMENT_PERSISTENCE_FAILED | false | 저장 실패. 같은 payload 재전송 금지. `correlation_id`로 문의 |
| 500 | INTERNAL_ERROR | false | 예기치 않은 서버 오류. 재전송 금지. `correlation_id`로 문의 |

`retryable`은 429/503에서만 `true`이며, `true`인 응답에는 `Retry-After` 헤더가 함께 옵니다. `false`인 응답은 같은 payload를 다시 보내도 결과가 같으므로 요청·정책을 먼저 수정해야 합니다. 5xx code는 위 4개 고정 값만 사용하며, 내부 오류 원문을 code나 message로 노출하지 않습니다. 생성이 5xx/timeout이면 새 참조로 재생성하기 전에 같은 `external_shipment_ref`로 조회해 대사합니다. 구체적인 업무 code가 기본 code보다 우선합니다. 잘못된 JSON/schema는 400, 업무 정책은 422/409가 될 수 있습니다. 토큰 발급에는 인스턴스 메모리 기준 60초/10회(IP+client ID) 보호가 있습니다. 공유 전역 quota/SLA가 아니며 서버리스 인스턴스마다 다릅니다.

## 재생성과 검증

OpenAPI와 Postman, Markdown의 예제는 `src/lib/open-api/documentation-contract.test.ts`에서 실제 parser/formatter와 대조합니다. `node scripts/generate-customer-oms-docs.mjs`로 HTML 및 공개 다운로드 사본을 생성합니다. PDF는 `python3 scripts/generate-customer-oms-guide-pdf.py`로 생성하고 Poppler로 실제 렌더링합니다. PDF에는 ReportLab과 저장소의 OFL 라이선스 NotoSansCJKkr-Regular.ttf 폰트가 필요합니다.

API 전체 schema: `customer-oms-shipment-openapi.yaml`. 배포/backfill/rollback: `customer-oms-shipment-migration-checklist.md`. LLM 작업 지침: `customer-oms-llm-integration-guide.ko.md`. 테스트 통과를 운영 배포·고객 주문·운송사 acceptance 증명으로 표현하지 않습니다.
