Rebuild estimate engine to effort-based SOW model

Replace flat per-service ranges with OurDigital's real quoting model
(role_rate × billing_rate 0.70 × standard hours), sourced from the
06_Working Template quotes:
- rate_card.yaml: role rate card, billing/basis/terms, tools, scaling bands
- sow_templates.yaml: basic + treatment task-hour templates
- estimate.py: assemble SOW from findings, scale Technical/On-page hours by
  properties_total, 제안가 = 합계 floored to 500k
- build_deck.py: estimate slide shows module 소계 + 제안가 (point)
- findings_to_service.md / SKILL.md / DESIGN.md: synced to new model

Validated: reproduces real Basic ₩10.5M and Treatment ₩25.0M exactly;
SHR (25 properties) scales to ₩71.5M, L'Escape (1) = ₩25.0M.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:51:52 +09:00
parent 01201ee4a2
commit 5f66f57a8e
7 changed files with 343 additions and 269 deletions

View File

@@ -250,17 +250,16 @@ def main():
[(body, 12, RGBColor(0x33, 0x3A, 0x45), False)],
])
# 8) Estimate
# 8) Estimate (effort-based: module subtotals + 제안가)
s = blank(prs)
header(s, "ESTIMATE", "예상 견적 (사전 추정 범위)")
if EST:
items = EST.get("line_items", [])
rows = min(len(items) + 1, 9)
tbl = s.shapes.add_table(rows, 3, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.45 * rows)).table
tbl.columns[0].width = Inches(6.0)
tbl.columns[1].width = Inches(2.0)
tbl.columns[2].width = Inches(3.6)
for j, htxt in enumerate(["항목", "단위", "금액(범위)"]):
header(s, "ESTIMATE", "예상 견적 (사전 추정)")
mods = (EST or {}).get("modules") if EST else None
if mods:
rows = len(mods) + 2 # header + modules + proposal
tbl = s.shapes.add_table(rows, 2, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.5 * rows)).table
tbl.columns[0].width = Inches(8.4)
tbl.columns[1].width = Inches(3.2)
for j, htxt in enumerate([f"{EST.get('service', 'SEO')} — 구분", "소계"]):
c = tbl.cell(0, j)
c.text = htxt
c.fill.solid()
@@ -268,20 +267,29 @@ def main():
for para in c.text_frame.paragraphs:
for r in para.runs:
_style(r, 12, WHITE, True)
unit_ko = {"one_time": "1회", "project": "프로젝트", "monthly": ""}
for i, it in enumerate(items[:rows - 1], 1):
amt = f"{it['amount_min']:,}~{it['amount_max']:,}"
for j, v in enumerate([it["label"], unit_ko.get(it["unit"], it["unit"]), amt]):
for i, m in enumerate(mods, 1):
for j, v in enumerate([m["name"], f"{int(round(m['subtotal'])):,}"]):
c = tbl.cell(i, j)
c.text = v
for para in c.text_frame.paragraphs:
for r in para.runs:
_style(r, 11, NAVY, False)
tot = EST.get("totals", {})
textbox(s, 0.85, 2.0 + 0.45 * rows + 0.2, 11.6, 1.2, [
[("총계(범위): ", 14, NAVY, True),
(f"{tot.get('grand_min', 0):,} ~ {tot.get('grand_max', 0):,}", 14, RED, True)],
[(EST.get("disclaimer", ""), 10, GREY, False)],
pr = len(mods) + 1
for j, v in enumerate(["제안가 (절사 적용 · 부가세 별도)", f"{EST.get('proposal', 0):,}"]):
c = tbl.cell(pr, j)
c.text = v
c.fill.solid()
c.fill.fore_color.rgb = LIGHT
for para in c.text_frame.paragraphs:
for r in para.runs:
_style(r, 13 if j else 12, RED if j else NAVY, True)
sc = EST.get("scope", {})
note = f"청구율 {int(EST.get('billing_rate', 0.7) * 100)}% · 일8h/월4주 · SOW 기반"
if sc.get("hours_multiplier", 1.0) != 1.0:
note += f" · 프로퍼티 {sc.get('properties_total')}×{sc['hours_multiplier']:g}"
textbox(s, 0.85, 2.2 + 0.5 * rows, 11.6, 1.3, [
[(note, 10, GREY, False)],
[(EST.get("disclaimer", ""), 9, GREY, False)],
])
else:
textbox(s, 0.85, 2.2, 11.6, 1.0, [[("견적 데이터(estimate.json) 미연결 — estimate.py 실행 후 재생성", 13, GREY, False)]])

View File

@@ -1,19 +1,26 @@
#!/usr/bin/env python3
"""Generate a ranged 견적 (estimate) from findings.json using the OurDigital rate card.
"""Effort-based 견적 generator for the ourdigital-presales-seo skill (Stage 5).
Part of the ourdigital-presales-seo skill (Stage 5). Maps detected findings to
rate-card service lines (see references/findings_to_service.md) and emits:
- 05_estimate_ko.md (Korean line-item quote)
- data/estimate.json (consumed by build_deck.py)
- 05_estimate.xlsx (spreadsheet quote)
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 findings.json --rate-card ../references/rate_card.yaml \
--out-dir ./audits/2026-05-27-presales --seq 1
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
@@ -22,210 +29,189 @@ from openpyxl.styles import Alignment, Font, PatternFill
def won(n):
return f"{n:,}"
return f"{int(round(n)):,}"
def select_services(f):
"""Return {service_key: [reasons]} based on findings signals."""
chosen = {}
d = f.get("discovery", {})
t = f.get("technical", {})
e = f.get("entity", {})
m = f.get("measurement", {})
def need(key, reason):
chosen.setdefault(key, [])
if reason not in chosen[key]:
chosen[key].append(reason)
# Crawlability / indexation
if d.get("sitemap_status", 200) != 200 or d.get("robots_sitemap_declared", True) is False:
need("technical_audit", "sitemap/robots 색인 이슈")
need("technical_remediation", "sitemap 복구·크롤성 개선")
# Core Web Vitals
cwv = t.get("cwv", {})
if (cwv.get("perf", 1) < 0.5 or cwv.get("cls", 0) > 0.1
or cwv.get("lcp_ms", 0) > 2500 or cwv.get("ttfb_ms", 0) > 600):
need("technical_audit", "Core Web Vitals 취약")
need("technical_remediation", "CWV(CLS/LCP/TTFB) 개선")
# Schema / entity
if (t.get("schema", {}).get("org") in ("bare", "none") or e.get("panel") != "hotel"
or e.get("name_split") or e.get("legacy_contamination")
or (e.get("subbrands_with_entity", 0) == 0 and e.get("subbrands_total", 0) > 0)):
need("schema_build", "Organization/Hotel schema·브랜드 표기 정합")
need("onpage_entity", "엔티티·서브브랜드 최적화")
# Local
hygiene = " ".join(d.get("url_hygiene", [])).lower()
if ((e.get("properties_with_entity", 0) == 0 and e.get("properties_total", 0) > 0)
or "local" in hygiene or "gbp" in hygiene or "dup_path" in hygiene):
need("local_seo", "프로퍼티 로컬·GBP 정합")
# Measurement
if m.get("gsc_access") is False or m.get("ga4_access") is False:
need("ga4_impl", "측정 환경(GA4) 구축")
need("dashboard", "리포팅 대시보드 구축")
if m.get("tag_gaps"):
need("gtm_setup", "태그 관리(GTM) 구축")
# On-page hygiene
if t.get("meta_dupe") or t.get("title_i18n_mismatch") or t.get("hreflang") == "incomplete":
need("onpage_entity", "Meta/Title/hreflang 정리")
return chosen
def scope_multiplier(rate, count):
sc = rate.get("scaling", {})
bands = sc.get("bands", [[1, 1.0]])
c = max(int(count or 0), 1)
for mx, m in bands:
if c <= mx:
return float(m)
return float(bands[-1][1])
DRIVER_LABEL = {"properties_total": "프로퍼티", "subbrands_total": "서브브랜드"}
def pick_baseline(f, override):
if override:
return override
severities = {x.get("severity") for x in f.get("findings", [])}
props = f.get("entity", {}).get("properties_total", 0) or 0
return "treatment" if ("critical" in severities or props > 3) else "basic"
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 assemble(f, rate, sow, baseline, billing):
roles = rate["role_rates"]
props = f.get("entity", {}).get("properties_total", 0)
mult = scope_multiplier(rate, props)
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, props, tpl["service"]
def build_line_items(chosen, rate, f):
months = rate["defaults"]["retainer_months"]
order = {"one_time": 0, "project": 1, "monthly": 2}
items = []
for key, reasons in chosen.items():
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": 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
ROLE_KO = {
"ceo": "대표", "evp": "전무", "svp": "상무", "technical_advisor": "기술고문",
"director": "이사", "senior_manager": "부장", "deputy_manager": "차장",
"manager": "과장", "assistant_manager": "대리", "junior": "주임",
"associate": "사원", "intern": "인턴",
}
def totals(items):
one = [i for i in items if i["unit"] != "monthly"]
mon = [i for i in items if i["unit"] == "monthly"]
return {
"one_time_min": sum(i["amount_min"] for i in one),
"one_time_max": sum(i["amount_max"] for i in one),
"monthly_min": sum(i["amount_min"] for i in mon),
"monthly_max": sum(i["amount_max"] for i in mon),
"grand_min": sum(i["amount_min"] for i in items),
"grand_max": sum(i["amount_max"] for i in items),
}
UNIT_KO = {"one_time": "1회", "project": "프로젝트", "monthly": ""}
def write_md(path, quote_no, date, prospect, items, tot, months, disclaimer):
L = [f"# 견적서 (Pre-sales 추정) — {prospect}",
"", f"- **견적번호**: {quote_no}", f"- **작성일**: {date}",
f"- **대상**: {prospect}", f"- **공급자**: OurDigital (andrew.yim@ourdigital.org)",
"", "## 견적 내역", "",
"| 항목 | 근거 | 단위 | 수량 | 단가(범위) | 금액(범위) |",
"|---|---|---|---:|---|---|"]
for i in items:
L.append(f"| {i['label']} | {i['reason']} | {UNIT_KO.get(i['unit'], i['unit'])} | {i['qty']} | "
f"{won(i['unit_min'])}~{won(i['unit_max'])} | {won(i['amount_min'])}~{won(i['amount_max'])} |")
L += ["", "## 합계 (범위)", "",
f"- 일회성/프로젝트: **{won(tot['one_time_min'])} ~ {won(tot['one_time_max'])}**",
f"- 월 운영({months}개월 기준): **{won(tot['monthly_min'])} ~ {won(tot['monthly_max'])}**",
f"- 총계: **{won(tot['grand_min'])} ~ {won(tot['grand_max'])}**",
"", f"> {disclaimer}"]
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:
L.append(f"> 포트폴리오 규모 반영: 프로퍼티 {q['scope']['properties_total']}개 기준 Technical/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, quote_no, date, prospect, items, tot, months, disclaimer):
def write_xlsx(path, q):
wb = Workbook()
ws = wb.active
ws.title = "견적"
hdr = PatternFill("solid", fgColor="11243D")
hf = Font(color="FFFFFF", bold=True)
ws.append([f"견적서 (Pre-sales 추정) — {prospect}"])
ws.append([f"견적번호 {quote_no}", f"작성일 {date}", "공급자 OurDigital"])
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 = ["항목", "근거", "단위", "수량", "단가 min", "단가 max", "금액 min", "금액 max"]
cols = ["구분", "세부 업무", "담당", "시간(h)", "합계(원)"]
ws.append(cols)
hdr_row = ws.max_row
for c in range(1, len(cols) + 1):
cell = ws.cell(row=ws.max_row, column=c)
cell.fill = hdr
cell.font = hf
for i in items:
ws.append([i["label"], i["reason"], UNIT_KO.get(i["unit"], i["unit"]), i["qty"],
i["unit_min"], i["unit_max"], i["amount_min"], i["amount_max"]])
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(["일회성/프로젝트 합계", "", "", "", "", "", tot["one_time_min"], tot["one_time_max"]])
ws.append([f"월 운영 합계 ({months}개월)", "", "", "", "", "", tot["monthly_min"], tot["monthly_max"]])
ws.append(["총계", "", "", "", "", "", tot["grand_min"], tot["grand_max"]])
ws.cell(row=ws.max_row, column=1).font = Font(bold=True)
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([disclaimer])
widths = [34, 30, 8, 6, 12, 12, 14, 14]
for idx, w in enumerate(widths, 1):
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="Generate ranged 견적 from findings.json")
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, help="quote sequence number (NNN)")
ap.add_argument("--seq", type=int, default=1)
ap.add_argument("--baseline", choices=["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)
billing = args.billing if args.billing is not None else rate["billing_rate"]
baseline = pick_baseline(f, args.baseline)
modules, grand, mult, props, service = assemble(f, rate, sow, baseline, billing)
rounding = rate["rounding_unit"]
proposal = int(math.floor(grand / rounding) * rounding)
prospect = f.get("prospect", {}).get("name", "(prospect)")
date = f.get("prospect", {}).get("audit_date") or datetime.date.today().isoformat()
year = date[:4]
quote_no = f"{rate.get('quote_prefix', 'OD')}-{year}-{args.seq:03d}"
disclaimer = rate["defaults"]["disclaimer_ko"]
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}"
chosen = select_services(f)
items, months = build_line_items(chosen, rate, f)
tot = totals(items)
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": {"properties_total": props,
"subbrands_total": f.get("entity", {}).get("subbrands_total", 0),
"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)
md_path = os.path.join(args.out_dir, "05_estimate_ko.md")
xlsx_path = os.path.join(args.out_dir, "05_estimate.xlsx")
json_path = os.path.join(data_dir, "estimate.json")
write_md(md_path, quote_no, date, prospect, items, tot, months, disclaimer)
write_xlsx(xlsx_path, quote_no, date, prospect, items, tot, months, disclaimer)
with open(json_path, "w", encoding="utf-8") as fh:
json.dump({"quote_no": quote_no, "date": date, "prospect": prospect,
"line_items": items, "totals": tot, "retainer_months": months,
"disclaimer": disclaimer}, fh, ensure_ascii=False, indent=2)
print(f"견적 {quote_no}: {len(items)} line items | "
f"one-time {won(tot['one_time_min'])}~{won(tot['one_time_max'])} | "
f"monthly {won(tot['monthly_min'])}~{won(tot['monthly_max'])}/{months}mo")
print(f"Wrote: {md_path}\n {xlsx_path}\n {json_path}")
print(f"견적 {quote_no} [{baseline}] 제안가 {won(proposal)} (합계 {won(grand)}) "
f"| 프로퍼티 {props} ×{mult:g} | 청구율 {int(billing*100)}%")
for m in modules:
print(f" {m['name']:24} {won(m['subtotal'])}")
if __name__ == "__main__":