estimate.py: scale local_seo/onpage_entity by portfolio size
Add configurable sub-linear scope-scaling bands to rate_card.yaml; estimate.py now multiplies monthly line-item rates by properties_total (local_seo) and subbrands_total (onpage_entity), with the scope note written into the 견적. Validated: L'Escape (1 property) stays at base 23-47M; SHR (25 properties, 5 sub-brands) scales to 54.8-110.6M (local ×4.5, on-page ×2.2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,3 +49,15 @@ services:
|
||||
defaults:
|
||||
retainer_months: 6 # default contract length for monthly line items
|
||||
disclaimer_ko: "본 견적은 공개 데이터 기반 사전 추정 범위이며, Search Console/Analytics 권한 확보 후 정밀 진단을 통해 확정됩니다."
|
||||
|
||||
# Scope scaling — monthly line items scale (sub-linearly) by portfolio size.
|
||||
# driver: a count under findings.entity (properties_total | subbrands_total).
|
||||
# bands: ordered [max_count, multiplier]; first band whose max_count >= count wins.
|
||||
# A 25-property chain costs more to run than a single hotel, but not 25x.
|
||||
scaling:
|
||||
local_seo:
|
||||
driver: properties_total
|
||||
bands: [[1, 1.0], [5, 1.6], [15, 2.8], [30, 4.5], [999999, 6.5]]
|
||||
onpage_entity:
|
||||
driver: subbrands_total
|
||||
bands: [[1, 1.0], [3, 1.6], [6, 2.2], [999999, 3.2]]
|
||||
|
||||
@@ -71,7 +71,26 @@ def select_services(f):
|
||||
return chosen
|
||||
|
||||
|
||||
def build_line_items(chosen, rate):
|
||||
DRIVER_LABEL = {"properties_total": "프로퍼티", "subbrands_total": "서브브랜드"}
|
||||
|
||||
|
||||
def scope_multiplier(rate, key, f):
|
||||
"""Sub-linear scope multiplier for a service, driven by portfolio size.
|
||||
|
||||
Returns (multiplier, driver, count). count is floored at 1 (unknown→base).
|
||||
"""
|
||||
rule = rate.get("scaling", {}).get(key)
|
||||
if not rule:
|
||||
return 1.0, None, None
|
||||
driver = rule["driver"]
|
||||
count = max(int(f.get("entity", {}).get(driver, 0) or 0), 1)
|
||||
for max_count, mult in rule["bands"]:
|
||||
if count <= max_count:
|
||||
return float(mult), driver, count
|
||||
return 1.0, driver, count
|
||||
|
||||
|
||||
def build_line_items(chosen, rate, f):
|
||||
months = rate["defaults"]["retainer_months"]
|
||||
order = {"one_time": 0, "project": 1, "monthly": 2}
|
||||
items = []
|
||||
@@ -79,11 +98,21 @@ def build_line_items(chosen, rate):
|
||||
svc = rate["services"][key]
|
||||
unit = svc["unit"]
|
||||
qty = months if unit == "monthly" else 1
|
||||
mult, driver, count = scope_multiplier(rate, key, f)
|
||||
umin = int(round(svc["min"] * mult))
|
||||
umax = int(round(svc["max"] * mult))
|
||||
reason = "; ".join(reasons)
|
||||
scope_note = None
|
||||
if mult != 1.0:
|
||||
scope_note = f"{DRIVER_LABEL.get(driver, driver)} {count}개 기준 ×{mult:g}"
|
||||
reason = f"{reason} [{scope_note}]"
|
||||
items.append({
|
||||
"key": key, "label": svc["label_ko"], "unit": unit, "qty": qty,
|
||||
"unit_min": svc["min"], "unit_max": svc["max"],
|
||||
"amount_min": svc["min"] * qty, "amount_max": svc["max"] * qty,
|
||||
"reason": "; ".join(reasons),
|
||||
"unit_min": umin, "unit_max": umax,
|
||||
"amount_min": umin * qty, "amount_max": umax * qty,
|
||||
"reason": reason,
|
||||
"scope_multiplier": mult, "scope_driver": driver,
|
||||
"scope_count": count, "scope_note": scope_note,
|
||||
})
|
||||
items.sort(key=lambda x: order.get(x["unit"], 9))
|
||||
return items, months
|
||||
@@ -175,7 +204,7 @@ def main():
|
||||
disclaimer = rate["defaults"]["disclaimer_ko"]
|
||||
|
||||
chosen = select_services(f)
|
||||
items, months = build_line_items(chosen, rate)
|
||||
items, months = build_line_items(chosen, rate, f)
|
||||
tot = totals(items)
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
|
||||
Reference in New Issue
Block a user