Built from real D.intelligence docs (not the 견적 자료 folder, which has only GA4/GTM + education — confirmed): - digital_branding: TNS 유학원 디지털 브랜딩 진단 컨설팅 → ₩9,000,000 (5 fixed stages) - digital_ads: 디하이브 디지털 광고·퍼포먼스 마케팅 대행 계약 → ₩6,000,000/월 retainer (media-spend commission % is per-deal, kept as a parameter — not invented) effort method now supports fixed-amount tasks (Unit Cost) and monthly retainers (unit: monthly); render shows 고정/—//월 and retainer/commission notes. Validated: branding ₩9.0M, ads ₩6.0M/월; no regression (SEO 25.0M, coaching 1.57M). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
96 lines
3.9 KiB
Python
96 lines
3.9 KiB
Python
"""Effort method: cost = role_rate × billing_rate × hours, grouped by module.
|
||
|
||
Tier auto-selection (size + premium-vertical floor) and sub-brand hours scaling
|
||
are driven by rate_card config. Reproduces real SEO quotes (10.5M/25.0M).
|
||
"""
|
||
import math
|
||
|
||
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(signals, rate):
|
||
v = (signals.get("vertical") or "").lower()
|
||
return any(t.lower() in v for t in rate.get("tiering", {}).get("premium_verticals", []))
|
||
|
||
|
||
def _scope_mult(rate, signals):
|
||
sc = rate.get("scaling", {})
|
||
driver = sc.get("driver", "subbrands_total")
|
||
bands = sc.get("bands", [[1, 1.0]])
|
||
count = max(int(signals.get(driver, 0) or 0), 1)
|
||
for mx, m in bands:
|
||
if count <= mx:
|
||
return float(m), driver, count
|
||
return float(bands[-1][1]), driver, count
|
||
|
||
|
||
def _pick_tier(signals, rate, available):
|
||
props = signals.get("properties_total", 0) or 0
|
||
subs = signals.get("subbrands_total", 0) or 0
|
||
if props > 5 or subs > 3:
|
||
tier = "treatment"
|
||
elif props <= 1 and subs == 0:
|
||
tier = "smb"
|
||
else:
|
||
tier = "basic"
|
||
if _is_premium(signals, rate):
|
||
tier = _higher(tier, rate.get("tiering", {}).get("premium_min_tier", "basic"))
|
||
if tier not in available:
|
||
tier = "basic" if "basic" in available else sorted(available, key=lambda t: TIER_ORDER.get(t, 9))[0]
|
||
return tier
|
||
|
||
|
||
def build(scope, rate, catalog):
|
||
tiers = catalog["tiers"]
|
||
signals = scope.get("signals", {})
|
||
tier = scope.get("tier") or "auto"
|
||
if tier == "auto":
|
||
tier = _pick_tier(signals, rate, set(tiers))
|
||
if tier not in tiers:
|
||
raise SystemExit(f"tier '{tier}' not in catalog tiers {list(tiers)}")
|
||
t = tiers[tier]
|
||
billing = scope.get("billing_rate") or t.get("billing_rate") or rate["billing_rate"]
|
||
mult, driver, dcount = _scope_mult(rate, signals)
|
||
roles = rate["role_rates"]
|
||
|
||
modules, grand, recurring = [], 0.0, False
|
||
for mod in t["modules"]:
|
||
tasks, sub = [], 0.0
|
||
for task in mod["tasks"]:
|
||
applied = mult if (task.get("scale") and mult != 1.0) else 1.0
|
||
unit = task.get("unit", "one_time")
|
||
if unit == "monthly":
|
||
recurring = True
|
||
if "fixed" in task: # quoted lump sum / Unit Cost (no labor-hour basis)
|
||
amt = task["fixed"] * applied
|
||
tasks.append({"task": task["task"], "desc": task.get("desc", ""), "role": "fixed",
|
||
"role_rate": None, "hours": None, "amount": amt,
|
||
"scaled": applied != 1.0, "unit": unit})
|
||
else:
|
||
hours = round(task["hours"] * applied, 1)
|
||
rr = roles[task["role"]]
|
||
amt = rr * billing * hours
|
||
tasks.append({"task": task["task"], "desc": task.get("desc", ""), "role": task["role"],
|
||
"role_rate": rr, "hours": hours, "amount": amt,
|
||
"scaled": applied != 1.0, "unit": unit})
|
||
sub += amt
|
||
modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks})
|
||
grand += sub
|
||
|
||
rounding = rate["rounding_unit"]
|
||
proposal = int(math.floor(grand / rounding) * rounding)
|
||
return {
|
||
"kind": "effort", "service": catalog["service"], "label": t.get("label", catalog["service"]),
|
||
"tier": tier, "billing_rate": billing, "recurring": recurring,
|
||
"notes": t.get("notes", []),
|
||
"scope": {"driver": driver, "driver_count": dcount,
|
||
"properties_total": signals.get("properties_total", 0),
|
||
"subbrands_total": signals.get("subbrands_total", 0), "hours_multiplier": mult},
|
||
"modules": modules, "subtotal_sum": grand, "proposal": proposal,
|
||
"rounding_unit": rounding, "stub": bool(catalog.get("_stub", False)),
|
||
}
|