Add luxury/vertical signal to auto-tiering

pick_baseline now applies a premium floor: if prospect.vertical matches
rate_card.tiering.premium_verticals (luxury/premium/deluxe/5성/특1급…), the
auto-selected tier is floored to premium_min_tier (default basic) so a premium
single property won't drop to the smb entry tier.

Validated: L'Escape (hotel_luxury, 1 property) smb -> basic (₩10.5M);
non-luxury single -> smb (₩3.0M); SHR chain -> treatment (₩29.5M) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 01:15:55 +09:00
parent 0496262cd5
commit 95d6fdf499
4 changed files with 36 additions and 9 deletions

View File

@@ -56,7 +56,7 @@ headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.jso
## Stage 5 — Estimate (견적) — REVIEW GATE ## Stage 5 — Estimate (견적) — REVIEW GATE
- `python scripts/estimate.py --findings <out>/data/findings.json --rate-card references/rate_card.yaml --sow references/sow_templates.yaml --out-dir <out> --seq <N> [--baseline basic|treatment] [--billing 0.70]` - `python scripts/estimate.py --findings <out>/data/findings.json --rate-card references/rate_card.yaml --sow references/sow_templates.yaml --out-dir <out> --seq <N> [--baseline basic|treatment] [--billing 0.70]`
- **Effort-based** (OurDigital real model): cost = role_rate × 청구율 × 표준 업무시간, by module; 제안가 = 합계 절사. findings auto-select a tier (**smb / basic / treatment**) and scale **On-page** hours sub-linearly by `subbrands_total` (cap ×2.0); Technical is fixed. `smb` tier bills at 0.55, others 0.70. Override with `--baseline` / `--billing` (quote premium single properties as basic/treatment manually). - **Effort-based** (OurDigital real model): cost = role_rate × 청구율 × 표준 업무시간, by module; 제안가 = 합계 절사. findings auto-select a tier (**smb / basic / treatment**) and scale **On-page** hours sub-linearly by `subbrands_total` (cap ×2.0); Technical is fixed. `smb` tier bills at 0.55, others 0.70. Override with `--baseline` / `--billing`. Premium verticals (luxury/5성/특1급…) are auto-floored to `basic` via `rate_card.tiering` (so a luxury single property won't drop to `smb`).
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. Present the 견적; get sign-off. - Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. Present the 견적; get sign-off.
- Logic in `references/findings_to_service.md`; rates/hours in `rate_card.yaml` + `sow_templates.yaml` (edit together). Reproduces real Basic ₩10.5M / Treatment ₩25.0M; SMB entry ~₩3M; chains stay SMB-acceptable (e.g. 25-property → ~₩29.5M). - Logic in `references/findings_to_service.md`; rates/hours in `rate_card.yaml` + `sow_templates.yaml` (edit together). Reproduces real Basic ₩10.5M / Treatment ₩25.0M; SMB entry ~₩3M; chains stay SMB-acceptable (e.g. 25-property → ~₩29.5M).

View File

@@ -8,8 +8,10 @@ floored to `rounding_unit`. Mirrors OurDigital/D.intelligence's real SOW-based q
- `treatment` if `properties_total > 5` OR `subbrands_total > 3` (multi-brand / chain) - `treatment` if `properties_total > 5` OR `subbrands_total > 3` (multi-brand / chain)
- `smb` if `properties_total <= 1` AND `subbrands_total == 0` (single-property SMB) - `smb` if `properties_total <= 1` AND `subbrands_total == 0` (single-property SMB)
- `basic` otherwise (small multi-property / mid) - `basic` otherwise (small multi-property / mid)
- override with `--baseline`. **Note:** tiering is by portfolio size only — quote a - **Premium floor:** if `prospect.vertical` matches `rate_card.tiering.premium_verticals`
single *premium* property as `basic`/`treatment` manually. (luxury/premium/deluxe/5성/특1급…), the auto-tier is floored to `tiering.premium_min_tier`
(default `basic`) — a luxury single property won't auto-drop to `smb`.
- override with `--baseline`.
Per-tier billing: `smb` uses `billing_rate: 0.55` (set in sow_templates); `basic`/ Per-tier billing: `smb` uses `billing_rate: 0.55` (set in sow_templates); `basic`/
`treatment` use `rate_card.billing_rate` (0.70). Override with `--billing`. `treatment` use `rate_card.billing_rate` (0.70). Override with `--billing`.

View File

@@ -27,6 +27,13 @@ terms:
validity_days: 14 # 견적 유효기간(약 2주) validity_days: 14 # 견적 유효기간(약 2주)
payment: "현금" payment: "현금"
# 자동 티어링 보정. 프리미엄/럭셔리 prospect 는 규모가 작아도 entry(smb) 아래로
# 자동 선택되지 않도록 최소 티어를 보장. prospect.vertical 문자열에 아래 용어가
# 포함되면 프리미엄으로 간주(부분 일치, 대소문자 무시).
tiering:
premium_verticals: ["luxury", "premium", "deluxe", "5_star", "5성", "특1급", "boutique_luxury"]
premium_min_tier: basic # 프리미엄 단일 프로퍼티 최소 티어 (smb < basic < treatment)
# 시간 단가 (KRW/hr) — 직급 기준. sow_templates.yaml의 task.role 이 이 키를 참조. # 시간 단가 (KRW/hr) — 직급 기준. sow_templates.yaml의 task.role 이 이 키를 참조.
role_rates: role_rates:
ceo: 180000 # 대표 ceo: 180000 # 대표

View File

@@ -44,17 +44,35 @@ def scope_multiplier(rate, f):
return float(bands[-1][1]), driver, count return float(bands[-1][1]), driver, count
def pick_baseline(f, override): TIER_ORDER = {"smb": 0, "basic": 1, "treatment": 2}
def _higher(a, b):
return a if TIER_ORDER.get(a, 0) >= TIER_ORDER.get(b, 0) else b
def is_premium(f, rate):
vertical = (f.get("prospect", {}).get("vertical") or "").lower()
terms = [t.lower() for t in rate.get("tiering", {}).get("premium_verticals", [])]
return any(t in vertical for t in terms)
def pick_baseline(f, override, rate):
if override: if override:
return override return override
e = f.get("entity", {}) e = f.get("entity", {})
props = e.get("properties_total", 0) or 0 props = e.get("properties_total", 0) or 0
subs = e.get("subbrands_total", 0) or 0 subs = e.get("subbrands_total", 0) or 0
if props > 5 or subs > 3: # multi-brand / chain if props > 5 or subs > 3: # multi-brand / chain
return "treatment" tier = "treatment"
if props <= 1 and subs == 0: # single-property SMB elif props <= 1 and subs == 0: # single property
return "smb" tier = "smb"
return "basic" # small multi-property / mid else: # small multi-property / mid
tier = "basic"
# premium/luxury floor: don't auto-drop a premium prospect to the entry tier
if is_premium(f, rate):
tier = _higher(tier, rate.get("tiering", {}).get("premium_min_tier", "basic"))
return tier
def assemble(f, rate, sow, baseline, billing): def assemble(f, rate, sow, baseline, billing):
@@ -176,7 +194,7 @@ def main():
with open(args.sow, encoding="utf-8") as fh: with open(args.sow, encoding="utf-8") as fh:
sow = yaml.safe_load(fh) sow = yaml.safe_load(fh)
baseline = pick_baseline(f, args.baseline) baseline = pick_baseline(f, args.baseline, rate)
tpl_billing = sow["baselines"][baseline].get("billing_rate") tpl_billing = sow["baselines"][baseline].get("billing_rate")
billing = (args.billing if args.billing is not None billing = (args.billing if args.billing is not None
else tpl_billing if tpl_billing is not None else rate["billing_rate"]) else tpl_billing if tpl_billing is not None else rate["billing_rate"])