> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs.vpms.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 요금제 설정화면 프론트엔드 가이드

> Rate Domain 재설계 (ADR-0001 / 0002 / 0003) 기반의 신규 요금제 / 일별 가격 / 제약 / 시즌 / 부가서비스 UI 재구성 가이드

본 문서는 백엔드 rate domain 재설계 (`refactor/rate-domain-redesign` 브랜치, 41 commits)
이후 변경된 GraphQL surface 를 기준으로 프론트엔드 설정화면을 재작업하기 위한 가이드다.

## 1. 개념 변화 요약

| 영역       | 이전 (legacy ari-\*)                      | 신규 (rate-plan / inclusion)                                                 |
| -------- | --------------------------------------- | -------------------------------------------------------------------------- |
| 도메인 컨테이너 | `AriPackage` 가 `AriRatePlan[]` 을 묶음     | `RatePlan` 자체가 1차 단위. Package 개념은 inclusion bundle 속성으로 흡수                 |
| 일별 가격    | `AriRate` + `AriRateAdjustment` 폴리모픽 계산 | `DailyRate` snapshot row + `RatePlan.pricingRules` 룰                       |
| 시즌 / 기간  | `AriPeriod` JSON daysOfWeek/Month/Year  | `RateSeason` 명명 엔티티 + `pricingRules.seasons` 인라인                           |
| 부가 서비스   | `AriInclusion` + `AriRatePlanInclusion` | `Inclusion` + `RatePlanInclusion` (override 지원)                            |
| 판매 통제    | (부재)                                    | `RatePlanRestriction` 신규: CTA/CTD/StopSell/LOS by date                     |
| 가격 ID    | `AriRate.id: Int`                       | `DailyRate.id: BigInt → String` (resolver 직렬화), `RatePlan.id: ULID String` |
| 변경 감사    | 자체 history 테이블                          | audit-svc 통합 발행 (`@vpms-cluster/audit`)                                    |
| 규칙 변경 적용 | 즉시 자동 cascade                           | 명시적 `RematerializationProposal` → 사용자 승인                                   |

## 2. UI 흐름 (재확인)

[ADR-0002 §2.5/2.6](../adr/0002-pricing-rules-and-ui-patterns) 에서 확정된 UX:

* **요금제 생성**: 5단계 Wizard
* **일상 운영**: Hybrid Grid + Side Panel
* **변경 영향 관리**: 규칙 변경 시 Proposal 알림 → 사용자가 \[재물질화 진행 / 부분 적용 / 나중에] 선택

## 3. RatePlan 생성 — Wizard 5단계

### Step 1 — 기본 정보

| UI 필드         | input 필드                         | 비고                                               |
| ------------- | -------------------------------- | ------------------------------------------------ |
| 요금제 이름        | `name`                           | required                                         |
| 코드            | `code`                           | required, 시설 단위 unique                           |
| 설명            | `description`                    | optional textarea                                |
| 예약 타입         | `type` (`rent` / `lodge`)        | required enum                                    |
| 공개 여부         | `isPublic`                       | default true                                     |
| 활성화           | `isActive`                       | default true                                     |
| 통화            | `currency`                       | ISO 4217 enum                                    |
| 기준 점유 / 최대 점유 | `baseOccupancy` / `maxOccupancy` | default 2 / 2                                    |
| 체크인 / 체크아웃 시간 | `checkInTime` / `checkOutTime`   | **minutes from midnight (Int)** — 예: 14:00 → 840 |
| 적용 객실타입       | `roomTypeIds: [ID!]`             | 다중 선택 ULID                                       |

**호출**: `createRatePlan(accommodationId, input: CreateRatePlanInput)`

```graphql theme={null}
mutation CreateRatePlan($accommodationId: ID!, $input: CreateRatePlanInput!) {
  createRatePlan(accommodationId: $accommodationId, input: $input) {
    id
    code
    name
    type
    baseOccupancy
    defaultAmount
    currency
    roomTypes { id name }
  }
}
```

### Step 2 — 기본 가격

| UI 필드    | input 필드                   | 비고                                                                                   |
| -------- | -------------------------- | ------------------------------------------------------------------------------------ |
| 평일 기본 가격 | `defaultAmount: Decimal!`  | required                                                                             |
| 요일 가산치   | `pricingRules.dayOfWeek`   | optional. `{monday: 1.0, friday: 1.5, saturday: 1.875, sunday: 1.5}` 형태. 1.0 = 가산 없음 |
| 시즌 가산치   | `pricingRules.seasons[]`   | optional. `{name, startMonth, startDay, endMonth, endDay, multiplier, priority?}`    |
| 결합 방식    | `pricingRules.combination` | `multiplicative` (기본) / `additive_to_base`                                           |

**Step 2 미리보기**: 사용자에게 "토요일 ₩150,000" 같은 결과를 즉시 보여주려면
`resolveDailyRate` 를 호출 (RatePlan 저장 전이라면 클라이언트 측에서 동일 공식 계산):

```graphql theme={null}
query Preview($ratePlanId: ID!, $roomTypeId: ID!, $stayDate: Date!) {
  resolveDailyRate(ratePlanId: $ratePlanId, roomTypeId: $roomTypeId, stayDate: $stayDate) {
    amount
    currency
    resolutionSource
  }
}
```

> `pricingRules` 는 JSON scalar 이며 다음 shape:
>
> ```json theme={null}
> {
>   "dayOfWeek": { "saturday": 1.875, "sunday": 1.5 },
>   "seasons": [
>     { "name": "여름성수기", "startMonth": 7, "startDay": 1,
>       "endMonth": 8, "endDay": 31, "multiplier": 1.3, "priority": 10 }
>   ],
>   "combination": "multiplicative"
> }
> ```

### Step 3 — 점유 가격

점유 인원별 가격을 별도 row 로 둔다. baseOccupancy 와 다른 인원 가격을 명시.

UI: 1인 / 2인 / 3인 / 4인 등 row 입력.

**호출**: `setRatePlanOccupancyPrices(ratePlanId, prices)` — 전량 교체.

```graphql theme={null}
mutation SetOccupancyPrices($ratePlanId: ID!, $prices: [RatePlanOccupancyPriceInput!]!) {
  setRatePlanOccupancyPrices(ratePlanId: $ratePlanId, prices: $prices) {
    occupancy
    amount
    pricingMode
  }
}
```

`pricingMode`:

* `absolute`: 해당 가격 자체 (예: 1인 ₩60,000)
* `delta_from_base`: baseOccupancy 가격 대비 +/- (예: 3인 +₩30,000)

### Step 4 — 제약

RatePlan 기본 LOS 와 사전 예약 제한.

| UI 필드    | input 필드        |
| -------- | --------------- |
| 기본 최소 박수 | `defaultMinLOS` |
| 기본 최대 박수 | `defaultMaxLOS` |

날짜별 CTA / CTD / StopSell 은 Step 4 가 아니라 **운영 화면의 Side Panel** 에서 설정한다
(`setRatePlanRestriction` / `setRatePlanRestrictionBulk`).

### Step 5 — 포함 서비스 (Inclusion)

RatePlan 에 포함할 inclusion 선택 + 수량/charge override.

**호출**: `setRatePlanInclusions(ratePlanId, inclusions)` — 전량 교체.

```graphql theme={null}
mutation SetInclusions($ratePlanId: ID!, $inclusions: [RatePlanInclusionInput!]!) {
  setRatePlanInclusions(ratePlanId: $ratePlanId, inclusions: $inclusions) {
    inclusion { id name }
    quantity
    chargeOverride
    isMandatory
    isComplimentary
  }
}
```

### Wizard 완료

모든 Step 의 input 을 모아 `createRatePlan` 1회 호출 후, Step 3/5 의 bulk mutation 들을
순차 호출한다. 트랜잭션 보장이 필요하다면 백엔드에 묶음 endpoint 가 별도 필요 (현 시점 미제공).

## 4. 일상 운영 — Hybrid Grid + Side Panel

### 좌측 Calendar Grid

**호출**: `getDailyRateCalendar`

```graphql theme={null}
query Calendar(
  $ratePlanId: ID!, $roomTypeId: ID!, $start: Date!, $end: Date!
) {
  getDailyRateCalendar(
    ratePlanId: $ratePlanId, roomTypeId: $roomTypeId
    startDate: $start, endDate: $end
  ) {
    stayDate
    amount
    currency
    resolutionSource      # daily_rate_snapshot / derived_from_parent / default_with_rules
    hasSnapshot           # true → ◆ 확정 / false → 🧮 규칙 계산값
    ratePlanChain
  }
}
```

**셀 표기**:

* `hasSnapshot=true` 인 셀 → ◆ 표시 (확정 snapshot)
* `hasSnapshot=false` → 일반 (규칙 계산값)
* 같은 날 `getRatePlanRestrictions` 응답 cross-reference 해서 🔒 CTA / 🚫 CTD / ⛔ StopSell 아이콘

### Restriction overlay

```graphql theme={null}
query Restrictions($ratePlanId: ID!, $start: Date!, $end: Date!) {
  getRatePlanRestrictions(ratePlanId: $ratePlanId, startDate: $start, endDate: $end) {
    id
    stayDate
    roomTypeId
    closedToArrival
    closedToDeparture
    stopSell
    minLengthOfStay
    maxLengthOfStay
  }
}
```

### 우측 Side Panel — 단일 셀 편집

* **가격 수정**: `setDailyRate(input)` 또는 `clearDailyRate(...)` (snapshot 삭제 → 규칙 계산값 복귀)
* **제약 수정**: `setRatePlanRestriction(input)` / `clearRatePlanRestriction(...)`

```graphql theme={null}
mutation SetCell($input: SetDailyRateInput!) {
  setDailyRate(input: $input) {
    id stayDate amount currency source
  }
}

mutation SetRestriction($input: SetRatePlanRestrictionInput!) {
  setRatePlanRestriction(input: $input) {
    id stayDate stopSell closedToArrival closedToDeparture minLengthOfStay maxLengthOfStay
  }
}
```

### 범위 일괄 수정

```graphql theme={null}
mutation BulkRate($input: SetDailyRateBulkInput!) {
  setDailyRateBulk(input: $input)   # → Int (touched count)
}
mutation BulkRestriction($input: SetRatePlanRestrictionBulkInput!) {
  setRatePlanRestrictionBulk(input: $input)
}
mutation ClearRange(
  $ratePlanId: ID!, $roomTypeIds: [ID!]!, $start: Date!, $end: Date!
) {
  clearDailyRateBulk(ratePlanId: $ratePlanId, roomTypeIds: $roomTypeIds, startDate: $start, endDate: $end)
  clearRatePlanRestrictionBulk(ratePlanId: $ratePlanId, roomTypeIds: $roomTypeIds, startDate: $start, endDate: $end)
}
```

`SetDailyRateBulkInput`:

* `amount` / `amountDelta` / `amountPercentDelta` 중 1개 택일
* `overrideExisting: false` → 기존 snapshot 있는 cell 은 건너뜀 (확정 가격 보호)
* 응답은 `Int` (touched cell 수)

## 5. RateSeason 관리 화면

별도 시즌 카탈로그 화면.

```graphql theme={null}
query Seasons($accommodationId: ID!) {
  getRateSeasonList(accommodationId: $accommodationId) {
    id code name startDate endDate rrule priority
  }
}

mutation CreateSeason($accommodationId: ID!, $input: CreateRateSeasonInput!) {
  createRateSeason(accommodationId: $accommodationId, input: $input) {
    id code name
  }
}
```

`rrule` 은 RFC 5545 iCalendar RRULE 문자열 (예: `FREQ=WEEKLY;BYDAY=FR,SA`).
명시 `startDate / endDate` 와 함께 사용 가능.

## 6. Inclusion 관리 화면

```graphql theme={null}
query Inclusions($accommodationId: ID) {
  getInclusionList(accommodationId: $accommodationId) {
    id code name description postingType charge currency taxIncluded
  }
}
```

`postingType`:

* `per_stay`: 체크인 시 1회 부과
* `per_night`: 박당 부과
* `per_person`: 1인당 1회
* `per_person_per_night`: 1인당 박당

생성/수정/삭제: `createInclusion` / `updateInclusion` / `deleteInclusion`.

## 7. Rematerialization Proposal 흐름 (중요)

RatePlan 의 `defaultAmount` / `pricingRules` / `derivationRule` 을 수정하면 백엔드는
**자동으로 미래 DailyRate snapshot 을 덮어쓰지 않는다** (ADR-0001 §2.2.2).
대신 `RematerializationProposal` 을 생성하고 사용자 승인을 기다린다.

### UI 패턴

1. 사용자가 `updateRatePlan` 으로 규칙 변경 → 응답은 정상 (RatePlan row 만 갱신됨)
2. 프론트는 사용자에게 banner 표시:
   > ⚠️ 규칙 변경이 저장되었습니다.
   > 영향 받는 미래 snapshot 47건 / 빈 날짜 683건.
   > \[재물질화 진행] \[나중에]
3. 백엔드가 비동기 분석을 끝내면 `RematerializationProposal` 이 조회 가능해진다.
4. 사용자가 \[재물질화 진행] 클릭 → `applyRematerializationProposal(id)` 호출
5. 또는 \[나중에] / \[폐기] → `discardRematerializationProposal(id)`

### Proposal 조회

```graphql theme={null}
query Proposal($id: ID!) {
  getRematerializationProposal(id: $id) {
    id
    ratePlanId
    triggerKind            # pricing_rules / default_amount / derivation / occupancy
    affectedSnapshots      # 영향 받는 기존 snapshot 수
    affectedComputed       # 영향 받지만 snapshot 없는 날짜 수
    status                 # pending / applied / partial / discarded / expired
    expiresAt              # 1시간 TTL
    appliedAt
    createdAt
  }
}
```

> Proposal id 는 현재 `updateRatePlan` 응답에 포함되지 않는다. 다음 두 가지 중 하나:
>
> * 백엔드가 응답에 proposal id 를 포함하도록 surface 확장을 별도 합의
> * 프론트가 별도 `getRematerializationProposals(ratePlanId, status: pending)` 형태 list query 를 요청 (현 시점 미제공 — 필요 시 백엔드 PR)

## 8. Audit 표기 (감사)

엔티티에 `lastModifiedBy` (User ULID), `updatedAt`, `lastModifiedReason` 보조 필드가 있다.
Side Panel "이력" 섹션에 표시 권장:

* "정다운, 2026-05-10 14:32 · 가격 조정 (사유: 성수기 인상)"

전체 변경 이력은 **audit-svc 의 별도 query** 로 조회한다 (core-svc 는 발행만 함).
audit-svc surface 는 audit-svc GraphQL endpoint 별도 호출.

## 9. Error Codes (UI 처리 가이드)

| Error code                  | 의미                                     | UI 처리 권장                |
| --------------------------- | -------------------------------------- | ----------------------- |
| `RATE_PLAN_NOT_FOUND`       | 요금제 미존재 / soft-delete                  | 페이지 새로고침 안내             |
| `INVALID_PRICING_RULES`     | pricingRules 형식 / multiplier 오류        | Step 2 인라인 에러 표시        |
| `INVALID_DATE_RANGE`        | startDate > endDate                    | 폼 인라인 에러                |
| `BULK_AMOUNT_REQUIRED`      | bulk 입력에 amount / delta / percent 누락   | Bulk 다이얼로그 인라인 에러       |
| `RESTRICTION_VIOLATION`     | 예약 시 CTA/CTD/StopSell/LOS 위반           | 예약 화면 모달, 위반 일자 강조      |
| `PROPOSAL_NOT_APPLICABLE`   | proposal status 가 pending/partial 이 아님 | 재조회 후 banner 갱신         |
| `PROPOSAL_EXPIRED`          | 1h TTL 초과                              | 사용자 안내, RatePlan 재저장 유도 |
| `DERIVATION_DEPTH_EXCEEDED` | 파생 chain 깊이 5 초과                       | 부모 ratePlan 단순화 요청      |
| `DERIVATION_CYCLE_DETECTED` | parent 체인 순환                           | 부모 ratePlan 재선택         |

## 10. GraphQL Operation 카탈로그

### Query (9)

* `getRatePlanList(accommodationId, isActive)`
* `getRatePlan(id)`
* `getDailyRateCalendar(ratePlanId, roomTypeId, startDate, endDate, occupancy)`
* `resolveDailyRate(ratePlanId, roomTypeId, stayDate, occupancy)`
* `getRatePlanRestrictions(ratePlanId, startDate, endDate)`
* `getInclusionList(accommodationId)`
* `getRateSeasonList(accommodationId)` / `getRateSeason(id)`
* `getRematerializationProposal(id)`

### Mutation (22)

**RatePlan 메타**: `createRatePlan` / `updateRatePlan` / `deleteRatePlan`
**DailyRate**: `setDailyRate` / `setDailyRateBulk` / `clearDailyRate` / `clearDailyRateBulk`
**Restriction**: `setRatePlanRestriction` / `setRatePlanRestrictionBulk` / `clearRatePlanRestriction` / `clearRatePlanRestrictionBulk`
**RatePlan 하위 전량 교체**: `setRatePlanOccupancyPrices` / `setRatePlanInclusions` / `setRatePlanChannelMappings`
**Inclusion**: `createInclusion` / `updateInclusion` / `deleteInclusion`
**RateSeason**: `createRateSeason` / `updateRateSeason` / `deleteRateSeason`
**Rematerialization**: `applyRematerializationProposal` / `discardRematerializationProposal`

## 11. legacy 제거 체크리스트

기존 UI 에서 다음을 모두 제거:

* ☐ AriPackage 관련 화면 / 컨테이너 개념
* ☐ AriRate / AriRatePlan / AriPeriod / AriInclusion CRUD 화면
* ☐ Adjustment(multiplier, adjustment, period) 입력 UI
* ☐ `appliedRatePlanId / appliedRateId / appliedAdjustmentFlat / appliedAdjustmentMultiplier` 등 legacy 필드 참조
* ☐ `ratePlanVersion / rateIdVersion` 표시 (BlockComposition archive 는 deprecated)
* ☐ `currencySymbol` 표시 → `currency` 코드만 받아 `Intl.NumberFormat(locale, { style: 'currency', currency })` 로 클라이언트 변환

신규로 추가할 것:

* ☑ ratePlanId / roomTypeId 가 ULID 문자열임을 인지 (UUID 와 다름, 26자 Crockford base32)
* ☑ DailyRate / RatePlanRestriction 의 `id` 는 GraphQL `ID!` (string) 이지만 백엔드에서 BigInt — 그대로 string 비교/저장 가능
* ☑ 가격 입력은 `Decimal` 스칼라 (string 으로 전송, 부동소수점 손실 방지)
* ☑ `pricingRules` 는 JSON scalar (객체 직렬화)

## 12. 참고 ADR / 문서

* [ADR-0001 Rate Domain 재설계](/adr/0001-rate-domain-redesign)
* [ADR-0002 Pricing Rules & UI Patterns](/adr/0002-pricing-rules-and-ui-patterns)
* [ADR-0003 마이그레이션 런북](/adr/0003-rate-domain-migration-runbook)
* [API Reference: rate-plan](/api-reference/core-svc/rate-plan)
