Skip to main content

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.

ADR-0002: Pricing Rules Storage & UI Patterns

  • Status: Proposed
  • Date: 2026-05-15
  • Authors: 정다운 (daun@vendit.co.kr)
  • Supersedes: —
  • Related: ADR-0001
  • Affected: apps/core-svc/src/domains/rate-plan, apps/ari-ui (또는 후속 UI 앱)

1. Context

ADR-0001에서 Rate Domain 재설계 방향(RatePlan + DailyRate + Restriction + Inclusion + Audit)을 확정했다. 그러나 다음 두 가지는 명세되지 않았다:
  1. Pricing Rules 저장 방식 — 요일 가산치(토 +87.5%), 시즌 가산치(7-8월 +30%)를 RatePlan 수준에서 어떻게 표현할 것인가
  2. UI 패턴 — RatePlan 생성·운영 화면을 어떻게 구성할 것인가
UI 측 결정이 데이터 모델(특히 DailyRate.source enum과 resolver 우선순위)에 영향을 주므로 별도 ADR로 분리하여 다룬다.

2. Decision

2.1 RatePlan 가격 결정 구조 — Rule(설정) + Snapshot(확정) 분리

ADR-0001 §2.2.1·§2.2.2의 Price Resolution ContractMaterialization Rules를 전제로 한다.
  • RatePlan설정/레시피(defaultAmount, pricingRules, occupancyPrice, derivationRule)를 보관한다.
  • DailyRate row는 특정 stayDate최종 판매가 snapshot이다. 단순 “override”가 아니라 예약 엔진·OTA에 노출되는 영속화된 확정값이다.
  • 자식 RatePlan은 부모의 snapshot에 derivationRule을 적용한 결과를 자신의 snapshot으로 가질 수 있다.
3개 옵션 비교:
옵션저장 형태트레이드오프
A. Pre-materializeWizard 완료 시 24개월 DailyRate row 일괄 생성row 폭증. 시설당 5,500+ rows × roomType. Hybrid 원칙 위반
B. Rule OnlyRatePlan.pricingRules JSON만 저장, 매번 resolver 계산row가 전혀 없음 → “확정 가격” 개념 부재. OTA push·Audit·Yield 연동 모두 문제
C. Rule + Selective Snapshot규칙은 RatePlan에 보관. 사용자가 명시적으로 확정한 날짜만 DailyRate row 생성resolver는 ADR-0001 §2.2.1 순서대로 row → derivation → rule 평가. 가장 깔끔
채택: 옵션 C. DailyRate row 생성 조건은 ADR-0001 §2.2.2(Materialization Rules)를 따른다.

RatePlan.pricingRules JSON 스키마

interface PricingRules {
  // 요일별 가산치 (1.0 = 기본가, 1.5 = +50%)
  dayOfWeek?: {
    monday?: number;
    tuesday?: number;
    wednesday?: number;
    thursday?: number;
    friday?: number;
    saturday?: number;
    sunday?: number;
  };

  // 시즌별 가산치 (RateSeason 참조 또는 인라인)
  seasons?: Array<{
    name: string;
    startMonth: number;   // 1-12
    startDay: number;     // 1-31
    endMonth: number;
    endDay: number;
    multiplier: number;   // base * multiplier
    priority?: number;    // 충돌 시 우선순위 (높을수록 우선)
  }>;

  // 결합 방식
  combination?: 'multiplicative' | 'additive_to_base';
  // 기본 multiplicative: base × dow × season
  // additive_to_base: base × (1 + (dow-1) + (season-1))
}
기본 결합 방식: multiplicative
  • 예: base 80,000 × Saturday 1.875 × Summer 1.3 = 195,000

2.2 Rate Resolution 우선순위

ADR-0001 §2.2.1을 본 ADR의 pricingRules 계산식으로 보강한다:
resolveDailyRate(ratePlanId, roomTypeId, date, occupancy):

  1. DailyRate(ratePlanId, roomTypeId, stayDate) row 존재?
     → 최종 판매가 snapshot 반환 (ADR-0001 §2.2.1 step 1)

  2. parentRatePlanId 존재?
     → parent.resolveDailyRate(date) × derivationRule
        (derivationRule은 fixed_amount | fixed_delta | percentage_delta — ADR-0001 §2.2.3)
     → 반환

  3. pricingRules로 계산 (ADR-0001 §2.2.1 step 3):
     baseAmount = RatePlanRoomType(ratePlanId, roomTypeId).defaultAmount   // RoomType별 override
                  ?? ratePlan.defaultAmount                                // 미설정 시 RatePlan 기본가
     dowMultiplier = pricingRules.dayOfWeek[weekday(date)] ?? 1.0
     seasonMultiplier = matchSeasons(date, pricingRules.seasons) ?? 1.0
     return baseAmount × dowMultiplier × seasonMultiplier

  4. occupancyPrice 적용 (occupancy != roomType.defaultSleeps)
     → RatePlanOccupancyPrice 조회 후 mode에 따라 적용
     → 점유 기준은 RoomType.defaultSleeps/maxSleeps 가 source of truth (ADR-0001 §2.2.1).

  4-1. (옵션) 게스트 유형 가산치 적용 — guestMix 가 지정된 경우만
       → RatePlanGuestTypeAdjustment 의 각 row 에 대해 (성인/아동/유아 카운트 × chargeOp)
          를 누적. appliesAfterBaseOccupancy=true 인 row 는 roomType.defaultSleeps 를
          초과한 인원에 대해서만 카운트한다.

  5. 통화 반올림 규칙 normalize (ADR-0001 §2.2.1 step 5)

  6. (Phase 4) Redis 캐시 set
캐시는 Phase 4 도입(ADR-0001 §3 Phase-gated). Phase 2의 resolver는 PostgreSQL 직조회로 동작하고, 캐시 미스는 오류가 아니라 성능 저하로 취급한다(ADR-0001 §2.7).

2.3 DailyRate.source enum 세분화

ADR-0001 §2.8의 source 개념을 다음으로 구체화한다. DailyRate는 단순 override가 아니라 확정된 최종 판매가 snapshot(ADR-0001 §2.2.1)이므로 source는 “어떤 경로로 확정되었는가”를 기록한다:
enum DailyRateSource {
  manual_user        // 사용자가 Grid에서 단일 셀 직접 수정
  manual_bulk        // 사용자가 일괄수정 다이얼로그로 범위 확정
  rematerialized     // 규칙 변경 후 사용자 승인을 거쳐 재물질화된 결과
  derived_cascade    // 부모 RatePlan 변경에 따른 자식 파생 재산정 결과 (사용자 승인 후)
  horizon_extension  // horizon extension job이 기본값을 선계산한 결과 (Phase 4)
  yield_engine       // 외부 yield management 엔진 push
  channel_pull       // 채널 매니저(OTA) 동기화 pull
  promotion          // Promotion(ADR 추후 예정) 적용 결과
}
중요한 원칙:
  • wizard_default 같은 source는 존재하지 않는다. Wizard 완료 시점에 DailyRate row를 만들지 않는다. Wizard는 RatePlan.defaultAmount + pricingRules만 세팅하고, 실제 row는 사용자가 명시적으로 확정한 시점에만 생성된다(ADR-0001 §2.2.2).
  • 모든 source는 “확정 시점이 명시적으로 존재”한다. 즉 row의 존재 자체가 “이 가격은 누군가/무언가가 의도적으로 확정한 값”이라는 의미를 갖는다.
  • UI는 row가 있으면 표기하여 “확정된 snapshot”임을 즉시 식별 가능하게 한다.

2.4 Pricing Rules 변경 시 행동 — Explicit Re-materialization

ADR-0001 §2.2.2의 핵심 규칙: 규칙 변경은 기존 DailyRate row를 자동으로 덮어쓰지 않는다. 본 ADR은 이를 UI 흐름으로 구체화한다.

2.4.1 변경 발생 시 즉시 동작

RatePlan.defaultAmount, pricingRules, derivationRule, RatePlanOccupancyPrice 변경 시:
  1. RatePlan/related row만 트랜잭션으로 업데이트 — DailyRate 테이블은 건드리지 않는다.
  2. AuditLog 기록entityType='RatePlan', beforeState/afterState에 변경 diff.
  3. 영향 범위 분석 job enqueue — BullMQ로 비동기 실행:
    • 미래 24개월 중 변경된 규칙에 의해 결과값이 달라지는 (stayDate × roomType) 셀 카운트
    • 셀별로 “현재 snapshot(있다면)“과 “새 규칙 적용 시 계산값”을 비교
    • 결과를 RematerializationProposal 임시 레코드로 저장 (1시간 TTL)
  4. 사용자에게 알림 — Grid 상단에 banner 표시:
    ⚠️ 규칙 변경이 저장되었습니다.
       • 영향 받는 미래 날짜 snapshot: 47건
       • 영향 받지만 snapshot 없는 날짜: 683건 (자동으로 새 규칙 적용)
       [ 변경 미리보기 ]  [ 재물질화 진행 ]  [ 나중에 ]
    

2.4.2 사용자 선택지

선택결과
나중에snapshot은 그대로. 신규 조회만 새 규칙 사용. proposal은 1시간 후 만료
변경 미리보기영향 받는 셀의 before/after 가격을 Grid 색상으로 오버레이 표시. 다시 결정 선택 가능
재물질화 진행proposal에 따라 DailyRate row를 갱신/생성. source=rematerialized. 단일 묶음 AuditLog (action=bulk_update) 생성
부분 재물질화미리보기 단계에서 셀을 선택해 일부만 적용 가능. 선택되지 않은 셀은 기존 snapshot 유지

2.4.3 핵심 보장

  • 확정된 snapshot은 사용자 명시 승인 없이 절대 변경되지 않는다.
  • 사용자가 명시적으로 “초기화” 클릭한 셀의 DailyRate row는 삭제되어 resolver가 규칙 기반 계산값으로 복귀.
  • 부모 RatePlan 변경에 따른 자식 cascade도 동일 규칙: 자식의 snapshot은 자동 변경되지 않고 proposal로만 표시.

2.4.4 Drift 방지 운영 가드

  • 미확정 상태 proposal이 30일 이상 누적되면 운영 대시보드에 경고
  • “이번 달 적용 안된 규칙 변경” 위젯을 Grid 상단에 노출
  • 채널 매니저 push 시점에는 항상 resolver 최종값을 사용 (proposal 미적용 상태와 무관)

2.5 RatePlan 생성 UI — Wizard 5단계

Step입력 항목저장 대상
1. 기본정보name, code, type, isPublic, checkInTime, checkOutTime, 적용 RoomType[] (base/max occupancy 는 각 RoomType.defaultSleeps/maxSleeps 가 source of truth)RatePlan + RatePlanRoomType
2. 기본가격defaultAmount, dayOfWeek 가산치, seasons 가산치RatePlan.defaultAmount + RatePlan.pricingRules
3. 점유가격1인/2인/3인/4인 가격 (absolute 또는 delta)RatePlanOccupancyPrice[]
4. 제약기본 minLOS/maxLOS, CTA/CTD 요일 기본값, 사전예약 제한RatePlan.defaultMinLOS/MaxLOS + RatePlanRestriction 기본 row
5. 포함서비스Inclusion 선택, 수량, override 여부RatePlanInclusion[]

Wizard UX 원칙

  • 실시간 미리보기: 각 Step에서 “2026-05-17 토요일 가격 = ?” 즉시 계산 표시
  • 이전 Step 수정 가능: 진행도 인디케이터 클릭 시 이전 Step으로 이동
  • Skip 허용: Step 3·4·5는 선택사항 (default값 사용 가능)
  • 완료 시 행동:
    1. RatePlan + 관련 row 생성 (트랜잭션)
    2. AuditLog create 기록 (afterState=전체 snapshot)
    3. Grid 화면으로 자동 이동

2.6 일상 운영 화면 — Hybrid Grid + Side Panel

좌측 Grid

RoomType (default 가격 표시)날짜 (해당 월 1일~말일)
셀 내용:
  • 가격(천원 단위 표시, 클릭 시 정확한 값)
  • 상태 아이콘: 🔒 CTA / 🚫 CTD / ⛔ StopSell / ◆ override
상호작용:
  • 단일 셀 클릭 → 우측 Side Panel에 상세 로드
  • 셀 인라인 수정 → 가격만 빠르게 변경 가능
  • 셀 드래그 → 범위 선택 → 일괄 수정 다이얼로그
  • 더블클릭 → 풀스크린 상세 모달

우측 Side Panel

선택된 (날짜 × RoomType) 셀에 대해:
  • 가격 상태 라벨:
    • snapshot이 있는 경우: ◆ 확정 snapshot + source 표시 (예: manual_user, rematerialized, channel_pull)
    • snapshot이 없는 경우: 🧮 규칙 계산값 (resolver step 3 결과)
  • 가격 값: 현재값 + “규칙 계산값 대비 ±X” diff 표시
    • 입력 후 [확정 저장] → DailyRate row 생성/갱신 (source=manual_user)
    • [초기화] → DailyRate row 삭제 → 규칙 계산값으로 복귀
  • 점유 가격: 1/2/3/4인 가격 미리보기 (resolver step 4 결과)
  • 제약 (ADR-0001 §2.3 — restriction도 날짜별 snapshot이 source of truth):
    • CTA / CTD / StopSell 토글, MinLOS / MaxLOS 입력
    • 저장 시 RatePlanRestriction row 생성/갱신
    • [초기화] → row 삭제 → 부모/RatePlan default로 상속 복귀
  • 포함서비스: 이 날짜에 적용되는 inclusion 목록 (변경 불가, 정보성)
  • 이력: AuditLog 최근 3건 (“정다운, 2026-05-10 14:32, ₩120,000 → ₩150,000, source=manual_user”)

상단 툴바

  • RatePlan 셀렉터 (BAR ▾)
  • 월 네비게이션 (◀ 2026-05 ▶, [오늘])
  • 액션 버튼: [✎ 편집], [⚡ 일괄수정], [💰 프로모션]
  • 검색 (특정 룸타입·기간)

하단 KPI 바 — Phase 후속

  • 평균가격 / 점유율 / RevPAR
  • 이번 ADR 범위 밖 (Booking 도메인 의존)
  • 자리는 비워두고 <KpiBarPlaceholder /> 컴포넌트로 마크
  • 별도 PR(Booking 도메인 연계)에서 채움

2.7 일괄 수정 (Bulk Edit) 다이얼로그

Grid에서 범위 선택 후 또는 [⚡ 일괄] 버튼 클릭 시:
대상: 2026-05-15 ~ 2026-05-31 (17일)
      스탠다드, 디럭스 (2 roomType)
      = 34 cell

작업: ○ 가격 +10%        ○ 가격 절대값 설정
      ○ 가격 -₩5,000     ○ CTA 적용
      ○ Stop Sell 적용   ○ MinLOS = 2

기존 snapshot 처리:
  ◉ 새 값으로 덮어쓰기 (기존 snapshot 갱신)
  ○ snapshot 없는 cell만 적용 (확정 가격 보호)
  ○ 미리보기로 cell별 선택 후 적용
모든 cell은 DailyRate row를 명시적으로 생성/갱신한다. 일괄 수정은 ADR-0001 §2.2.2의 “특정 날짜 범위 가격을 확정 적용한 경우”에 해당하는 source of materialization.
  • source: manual_bulk
  • 단일 AuditLog 항목으로 기록 (action=bulk_update, diff에 영향 범위 + 작업 종류 요약)
  • restriction 일괄 적용도 동일 흐름 (RatePlanRestriction row 생성/갱신)

2.8 시각적 표기 규칙

표기의미색상
확정 snapshot (DailyRate row 존재)회색 dot
🧮규칙 계산값 (DailyRate row 없음, resolver step 3)(표시 없음, 기본)
🔒CTA (Closed to Arrival)노랑
🚫CTD (Closed to Departure)주황
Stop Sell빨강
⚠️ 셀 외곽선재물질화 proposal 영향 받는 셀 (사용자 결정 대기)주황 외곽선
▓ 셀 배경현재 선택파랑 톤
음영 셀과거 날짜 (수정 가능하나 경고)회색 톤

3. Out of Scope

  • Booking 데이터 의존 KPI (Phase 후속 PR)
  • 채널별 가격 분배 UI (ADR-0005 예정)
  • Yield Engine 자동 가격 룰 UI (ADR-0004 예정)
  • 모바일 전용 UI (필요 시 후속)

4. Consequences

4.1 Positive

  • ✅ Pricing Rules JSON으로 시즌·요일 가격을 RatePlan 단위에 응집 → 운영 단순화
  • ✅ DailyRate 테이블은 “예외만” 저장 → storage·성능 효율
  • ✅ Wizard로 초보자 진입 장벽 ↓
  • ✅ Grid + Panel로 숙련자 효율 ↑
  • ✅ 일괄 수정·드래그 선택으로 yield manager 워크플로우 지원
  • ✅ override가 자동 삭제되지 않음 → 사용자 작업 보호

4.2 Negative

  • ❌ Resolver 복잡도 ↑ — pricingRules 계산 로직 + override 우선순위 + parent derivation
  • ❌ JSON 필드는 type-safe하지 않음 → Zod 등 runtime 검증 필수
  • ❌ Wizard와 Grid 이중 UI → 디자인·구현 비용 증가
  • ❌ 시즌 정의가 RatePlan 내부에 들어가면 여러 RatePlan에서 공유 불가 → 추후 RateSeason 분리 필요 시 마이그레이션 부담

4.3 Risks & Mitigations

RiskMitigation
pricingRules JSON 스키마 진화pricingRulesVersion: number 필드로 버전 관리, resolver에서 분기
Snapshot이 너무 많아져 Hybrid 의미 상실”snapshot 비율” 위젯 표시, 80% 초과 시 경고
사용자가 규칙 계산값과 snapshot 차이를 혼동UI에서 ◆/🧮 표기 + Side Panel “규칙 대비 ±X” 명시
일괄 수정 실수로 대량 snapshot 생성확인 다이얼로그 + 미리보기 단계 + AuditLog bulk_update
규칙 변경 후 재물질화 미진행으로 driftproposal 30일 누적 시 경고 + 운영 위젯에 “미적용 규칙 변경” 노출 (§2.4.4)
부분 재물질화 시 일관성 깨짐미리보기 단계에서 영향 범위 시각화. 채널 push는 항상 resolver 최종값 사용

5. Alternatives Considered

A1. Pre-materialize (Wizard 완료 시 24개월 row 생성)

  • ❌ 거부 — Hybrid storage 원칙(ADR-0001 2.2) 위반. row 수 폭증.

A2. Rule Only (DailyRate row 자체를 안 만듦)

  • ❌ 거부 — override가 별도 테이블 필요 → 결국 DailyRate 재발명. 또한 Audit·Yield 연동에서 row 단위 추적 필요.

A3. Spreadsheet UI를 메인 운영화면으로

  • ❌ 거부 — 일반 사용자 학습 장벽 높음. Power user는 일괄수정 다이얼로그 + 키보드 단축키로 커버.

A4. 명령 팔레트(Cmd+K) 기반 자연어 인터페이스

  • 보류 — 흥미롭지만 NLP 정확도·다국어 대응 비용 큼. v2 후속 검토.

A5. RateSeason을 별도 테이블로 즉시 분리

  • ❌ 이번 ADR에서는 거부 — pricingRules.seasons JSON으로 시작, RatePlan 간 공유 needs가 명확해질 때 분리(YAGNI).

6. Implementation Notes

6.1 RatePlan 스키마 추가 필드 (ADR-0001 보강)

model RatePlan {
  // ... ADR-0001의 필드들 ...

  defaultAmount       Decimal  @database.Decimal(15, 4)
  pricingRules        Json?    /// [PricingRules]
  pricingRulesVersion Int      @default(1)

  // ...
}

6.2 RematerializationProposal 임시 레코드

규칙 변경 시 영향 범위 분석 결과를 보관하는 임시 엔티티 (Redis 또는 PostgreSQL TTL row, Phase 4 의존성 없음):
model RematerializationProposal {
  id              Bytes    @id @database.ByteA   // ULID
  ratePlanId      Bytes    @database.ByteA
  triggeredBy     Bytes    @database.ByteA       // actor user
  triggerKind     String   @database.VarChar(32) // 'pricing_rules' | 'default_amount' | 'derivation' | 'occupancy'
  beforeRulesSnapshot Json
  afterRulesSnapshot  Json
  affectedSnapshots   Int  // 영향 받는 기존 DailyRate row 수
  affectedComputed    Int  // 영향 받지만 snapshot 없는 날짜 수
  proposalDetails Json     // cell별 before/after
  status          String   @database.VarChar(16) // 'pending' | 'applied' | 'partial' | 'discarded' | 'expired'
  expiresAt       DateTime
  createdAt       DateTime @default(now())

  @@index([ratePlanId, status])
  @@index([expiresAt])
}

6.3 Resolver 모듈 위치

apps/core-svc/src/domains/rate-plan/
  ├── services/
  │   ├── rate-resolver.service.ts             // 가격 해결 (ADR-0001 §2.2.1)
  │   ├── pricing-rules.service.ts             // pricingRules JSON 검증·계산
  │   ├── rematerialization.service.ts         // proposal 생성·적용 (§2.4)
  │   └── rate-audit.publisher.ts              // @vpms-cluster/audit 래퍼 (ADR-0001 §2.8)
  ├── schemas/
  │   └── pricing-rules.schema.ts              // Zod 스키마
  └── workers/
      └── rematerialization-analyzer.worker.ts // BullMQ: 영향 범위 분석
rate-audit.publisher.ts@vpms-cluster/auditpublishAuditLog / publishAuditLogBatch 를 rate domain 관점에서 호출하는 얇은 래퍼이다. 자체 저장소를 갖지 않고 audit-svc 로 발행만 한다.

6.3 Zod 스키마 (필수)

const PricingRulesSchema = z.object({
  dayOfWeek: z.object({
    monday: z.number().positive().optional(),
    // ... 7개 요일
  }).optional(),
  seasons: z.array(z.object({
    name: z.string().min(1).max(64),
    startMonth: z.number().int().min(1).max(12),
    // ...
    multiplier: z.number().positive(),
  })).optional(),
  combination: z.enum(['multiplicative', 'additive_to_base']).optional(),
});

6.4 UI 컴포넌트 분리

  • <RatePlanWizard /> — 5 Step 컨테이너
  • <DailyRateGrid /> — Grid (가상화 필수, 31 × N roomType × ratePlan 셀)
  • <DailyRateSidePanel /> — 선택된 셀 상세
  • <BulkEditDialog /> — 일괄 수정
  • <KpiBarPlaceholder /> — Booking 도메인 의존, placeholder

7. References