"""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 = [], 0.0 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 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}) 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, "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)), }