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:
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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 # 대표
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user