Files
our-claude-skills/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py
Andrew Yim 95d6fdf499 Add luxury/vertical signal to auto-tiering
pick_baseline now applies a premium floor: if prospect.vertical matches
rate_card.tiering.premium_verticals (luxury/premium/deluxe/5성/특1급…), the
auto-selected tier is floored to premium_min_tier (default basic) so a premium
single property won't drop to the smb entry tier.

Validated: L'Escape (hotel_luxury, 1 property) smb -> basic (₩10.5M);
non-luxury single -> smb (₩3.0M); SHR chain -> treatment (₩29.5M) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:15:55 +09:00

248 lines
11 KiB
Python
Executable File
Raw 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.
#!/usr/bin/env python3
"""Effort-based 견적 generator for the ourdigital-presales-seo skill (Stage 5).
Reproduces OurDigital/D.intelligence's real quoting model:
cost(task) = role_rate × billing_rate × standard_hours
assembled from sow_templates.yaml, grouped into modules, summed, then
제안가 = 합계 floored to rounding_unit (십만단위 절사).
findings.json selects the baseline (basic vs treatment) and scales scoped
task hours sub-linearly by portfolio size. Outputs:
- 05_estimate_ko.md (cover-sheet 견적)
- data/estimate.json (consumed by build_deck.py)
- 05_estimate.xlsx
Usage:
python estimate.py --findings data/findings.json \
--rate-card ../references/rate_card.yaml --sow ../references/sow_templates.yaml \
--out-dir <engagement> --seq 1 [--baseline basic|treatment] [--billing 0.70]
"""
import argparse
import datetime
import json
import math
import os
import yaml
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
def won(n):
return f"{int(round(n)):,}"
def scope_multiplier(rate, f):
"""Sub-linear hours multiplier from the configured driver (default subbrands_total)."""
sc = rate.get("scaling", {})
driver = sc.get("driver", "subbrands_total")
bands = sc.get("bands", [[1, 1.0]])
count = max(int(f.get("entity", {}).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
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(f, rate):
vertical = (f.get("prospect", {}).get("vertical") or "").lower()
terms = [t.lower() for t in rate.get("tiering", {}).get("premium_verticals", [])]
return any(t in vertical for t in terms)
def pick_baseline(f, override, rate):
if override:
return override
e = f.get("entity", {})
props = e.get("properties_total", 0) or 0
subs = e.get("subbrands_total", 0) or 0
if props > 5 or subs > 3: # multi-brand / chain
tier = "treatment"
elif props <= 1 and subs == 0: # single property
tier = "smb"
else: # small multi-property / mid
tier = "basic"
# premium/luxury floor: don't auto-drop a premium prospect to the entry tier
if is_premium(f, rate):
tier = _higher(tier, rate.get("tiering", {}).get("premium_min_tier", "basic"))
return tier
def assemble(f, rate, sow, baseline, billing):
roles = rate["role_rates"]
mult, driver, dcount = scope_multiplier(rate, f)
tpl = sow["baselines"][baseline]
modules = []
grand = 0.0
for mod in tpl["modules"]:
tasks, sub = [], 0.0
for t in mod["tasks"]:
applied = mult if (t.get("scale") and mult != 1.0) else 1.0
hours = round(t["hours"] * applied, 1)
rate_hr = roles[t["role"]]
amount = rate_hr * billing * hours
tasks.append({
"task": t["task"], "desc": t.get("desc", ""), "role": t["role"],
"role_rate": rate_hr, "hours": hours, "amount": amount,
"scaled": applied != 1.0,
})
sub += amount
modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks})
grand += sub
return modules, grand, mult, driver, dcount, tpl["service"]
ROLE_KO = {
"ceo": "대표", "evp": "전무", "svp": "상무", "technical_advisor": "기술고문",
"director": "이사", "senior_manager": "부장", "deputy_manager": "차장",
"manager": "과장", "assistant_manager": "대리", "junior": "주임",
"associate": "사원", "intern": "인턴",
}
def write_md(path, q):
L = [f"# 견적서 — {q['prospect']}",
"", f"- **제공 서비스**: {q['service']}",
f"- **견적번호**: {q['quote_no']} · **작성일**: {q['date']} · **유효기간**: ~{q['valid_until']}",
f"- **공급자**: {q['company']['legal_name']} (대표 {q['company']['ceo']}, {q['company']['contact']})",
f"- **산정 기준**: SOW 기반 · 청구율 {int(q['billing_rate']*100)}% · 일 8시간/월 4주 · {q['terms']['vat']} · 지급 {q['terms']['payment']}",
""]
if q["scope"]["hours_multiplier"] != 1.0:
dl = "브랜드/템플릿" if q["scope"]["driver"] == "subbrands_total" else "프로퍼티"
L.append(f"> 규모 반영: {dl} {q['scope']['driver_count']}개 기준 On-page 업무시간 ×{q['scope']['hours_multiplier']:g} (서브선형)")
L.append("")
L += ["## 견적 내역", "",
"| 구분 | 세부 업무 | 담당 | 시간(h) | 합계 |",
"|---|---|:--:|--:|--:|"]
for m in q["modules"]:
for i, t in enumerate(m["tasks"]):
grp = m["name"] if i == 0 else ""
mark = " *" if t["scaled"] else ""
L.append(f"| {grp} | {t['task']}{mark} | {ROLE_KO.get(t['role'], t['role'])} | {t['hours']:g} | {won(t['amount'])} |")
L.append(f"| | **{m['name']} 소계** | | | **{won(m['subtotal'])}** |")
L += ["", "## 합계", "",
f"- 합계: **{won(q['subtotal_sum'])}**",
f"- **제안가(절사 적용): {won(q['proposal'])}** ({q['terms']['vat']})", ""]
if q.get("tools"):
L += ["## 별도 비용 (조달 — 인력비와 별도)", ""]
for t in q["tools"]:
L.append(f"- {t['label']}: {t['unit']} ${t['price_usd']}{t['note']}")
L.append("")
L += ["---", f"> {q['disclaimer']}"]
if any(t["scaled"] for m in q["modules"] for t in m["tasks"]):
L.append("> \\* 포트폴리오 규모에 따라 업무시간이 스케일된 항목.")
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(L) + "\n")
def write_xlsx(path, q):
wb = Workbook()
ws = wb.active
ws.title = "견적"
ws.append([f"견적서 — {q['prospect']} ({q['service']})"])
ws.append([f"견적번호 {q['quote_no']}", f"작성일 {q['date']}", f"유효 ~{q['valid_until']}",
f"청구율 {int(q['billing_rate']*100)}%", q["terms"]["vat"]])
ws.append([])
cols = ["구분", "세부 업무", "담당", "시간(h)", "합계(원)"]
ws.append(cols)
hdr_row = ws.max_row
for c in range(1, len(cols) + 1):
cell = ws.cell(row=hdr_row, column=c)
cell.fill = PatternFill("solid", fgColor="11243D")
cell.font = Font(color="FFFFFF", bold=True)
for m in q["modules"]:
for i, t in enumerate(m["tasks"]):
ws.append([m["name"] if i == 0 else "", t["task"], ROLE_KO.get(t["role"], t["role"]),
t["hours"], int(round(t["amount"]))])
ws.append(["", f"{m['name']} 소계", "", "", int(round(m["subtotal"]))])
ws.cell(row=ws.max_row, column=2).font = Font(bold=True)
ws.cell(row=ws.max_row, column=5).font = Font(bold=True)
ws.append([])
ws.append(["", "합계", "", "", int(round(q["subtotal_sum"]))])
ws.append(["", "제안가(절사 적용)", "", "", int(q["proposal"])])
ws.cell(row=ws.max_row, column=2).font = Font(bold=True)
ws.cell(row=ws.max_row, column=5).font = Font(bold=True, color="C0392B")
ws.append([])
ws.append([q["disclaimer"]])
for idx, w in enumerate([22, 40, 8, 8, 16], 1):
ws.column_dimensions[chr(64 + idx)].width = w
wb.save(path)
def main():
ap = argparse.ArgumentParser(description="Effort-based 견적 from findings.json")
ap.add_argument("--findings", required=True)
ap.add_argument("--rate-card", required=True)
ap.add_argument("--sow", required=True)
ap.add_argument("--out-dir", default=".")
ap.add_argument("--seq", type=int, default=1)
ap.add_argument("--baseline", choices=["smb", "basic", "treatment"], default=None)
ap.add_argument("--billing", type=float, default=None)
args = ap.parse_args()
with open(args.findings, encoding="utf-8") as fh:
f = json.load(fh)
with open(args.rate_card, encoding="utf-8") as fh:
rate = yaml.safe_load(fh)
with open(args.sow, encoding="utf-8") as fh:
sow = yaml.safe_load(fh)
baseline = pick_baseline(f, args.baseline, rate)
tpl_billing = sow["baselines"][baseline].get("billing_rate")
billing = (args.billing if args.billing is not None
else tpl_billing if tpl_billing is not None else rate["billing_rate"])
modules, grand, mult, driver, dcount, service = assemble(f, rate, sow, baseline, billing)
props = f.get("entity", {}).get("properties_total", 0)
subs = f.get("entity", {}).get("subbrands_total", 0)
rounding = rate["rounding_unit"]
proposal = int(math.floor(grand / rounding) * rounding)
date = f.get("prospect", {}).get("audit_date") or datetime.date.today().isoformat()
d0 = datetime.date.fromisoformat(date)
valid_until = (d0 + datetime.timedelta(days=rate["terms"]["validity_days"])).isoformat()
quote_no = f"{rate.get('quote_prefix', 'OD')}-{date[:4]}-{args.seq:03d}"
disclaimer = ("본 견적은 공개 데이터 기반 사전 추정이며 표준 업무시간(SOW)·청구율 "
f"{int(billing*100)}% 기준입니다. Search Console/Analytics 권한 확보 후 정밀 "
"진단을 통해 과업 시간과 범위를 확정합니다. 외부 조달 항목은 인력비와 별도이며 조달 수수료 15%가 적용될 수 있습니다.")
q = {
"quote_no": quote_no, "date": date, "valid_until": valid_until,
"prospect": f.get("prospect", {}).get("name", "(prospect)"),
"service": service, "baseline": baseline, "billing_rate": billing,
"company": rate["company"], "terms": rate["terms"],
"scope": {"driver": driver, "driver_count": dcount,
"properties_total": props, "subbrands_total": subs,
"hours_multiplier": mult},
"modules": modules, "subtotal_sum": grand, "proposal": proposal,
"rounding_unit": rounding,
"tools": [dict(label=v["label"], unit=v["unit"], price_usd=v["price_usd"], note=v["note"])
for v in rate.get("tools", {}).values()],
"disclaimer": disclaimer,
}
os.makedirs(args.out_dir, exist_ok=True)
data_dir = os.path.join(args.out_dir, "data")
os.makedirs(data_dir, exist_ok=True)
write_md(os.path.join(args.out_dir, "05_estimate_ko.md"), q)
write_xlsx(os.path.join(args.out_dir, "05_estimate.xlsx"), q)
with open(os.path.join(data_dir, "estimate.json"), "w", encoding="utf-8") as fh:
json.dump(q, fh, ensure_ascii=False, indent=2)
print(f"견적 {quote_no} [{baseline}] 제안가 {won(proposal)} (합계 {won(grand)}) "
f"| {driver}={dcount} ×{mult:g} | 청구율 {int(billing*100)}%")
for m in modules:
print(f" {m['name']:24} {won(m['subtotal'])}")
if __name__ == "__main__":
main()