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:
2026-05-28 00:24:33 +09:00
parent 4f48ba3c59
commit 3a8edebfef
2 changed files with 46 additions and 5 deletions

View File

@@ -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)