Files
Andrew Yim 08cb20fc67 Add real digital_ads + digital_branding catalogs
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>
2026-05-28 02:06:59 +09:00

96 lines
3.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)),
}