Extract ourdigital-estimate-engine; presales-seo now calls it
New skill 96-ourdigital-estimate-engine: method-aware quoting engine (effort / coaching / procurement) with universal rate_card + per-service catalog. Real catalogs: seo (effort), education (coaching); stubs: digital_ads, digital_branding. Validated to reproduce real quotes — SEO basic ₩10.5M / treatment ₩25.0M, SHR chain ₩29.5M, L'Escape basic ₩10.5M, GA4/GTM coaching ₩1,570,000, procurement +15%. Refactor 95-ourdigital-presales-seo: remove rate_card.yaml, sow_templates.yaml, estimate.py (migrated to engine); add findings_to_scope.py; Stage 5 now maps findings→scope.json and calls the engine CLI. build_deck/kg_query unchanged; end-to-end validated on SHR (29.5M) + deck renders engine estimate.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OurDigital estimate engine — method-aware 견적 generator (CLI dispatcher).
|
||||
|
||||
Loads a generic scope.json + the universal rate_card + the service catalog entry,
|
||||
routes to the costing method (effort | coaching | procurement), enriches with
|
||||
quote metadata/terms, and renders 05_estimate_ko.md / .xlsx / data/estimate.json.
|
||||
|
||||
Usage:
|
||||
python estimate.py --rate-card references/rate_card.yaml --catalog-dir catalog \
|
||||
--scope scope.json --out-dir <dir> [--seq N]
|
||||
|
||||
scope.json: see scope.schema.json. Consuming skills (e.g. ourdigital-presales-seo)
|
||||
map their context into scope.json and call this CLI.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from methods import coaching, effort, procurement # noqa: E402
|
||||
import render # noqa: E402
|
||||
|
||||
METHODS = {"effort": effort, "coaching": coaching, "procurement": procurement}
|
||||
|
||||
DISCLAIMER = {
|
||||
"effort": ("본 견적은 공개/사전 정보 기반 추정이며 표준 업무시간(SOW)·청구율 {billing}% 기준입니다. "
|
||||
"권한 확보 후 정밀 진단을 통해 과업 시간과 범위를 확정합니다. 외부 조달 항목은 인력비와 별도, 조달 수수료 15%가 적용될 수 있습니다."),
|
||||
"coaching": ("본 견적은 레슨 시간 기준으로 작성되었으며(대면 100,000원/h, 화상 80,000원/h 등), 1:1 레슨 전제입니다. "
|
||||
"그룹 레슨·단체 워크숍/실습은 별도 협의가 필요합니다. 수업 구성은 사전상담으로 맞춤 조율됩니다."),
|
||||
"procurement": "조달 물품/서비스 견적이며 조달-관리 수수료 15%를 포함합니다. 실제 공급가는 계약 시점에 확정됩니다.",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="OurDigital estimate engine")
|
||||
ap.add_argument("--rate-card", required=True)
|
||||
ap.add_argument("--catalog-dir", required=True)
|
||||
ap.add_argument("--scope", required=True)
|
||||
ap.add_argument("--out-dir", default=".")
|
||||
ap.add_argument("--seq", type=int, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
with open(args.rate_card, encoding="utf-8") as fh:
|
||||
rate = yaml.safe_load(fh)
|
||||
with open(args.scope, encoding="utf-8") as fh:
|
||||
scope = json.load(fh)
|
||||
|
||||
service = scope["service"]
|
||||
cat_path = os.path.join(args.catalog_dir, f"{service}.yaml")
|
||||
if not os.path.exists(cat_path):
|
||||
sys.exit(f"ERROR: no catalog for service '{service}' at {cat_path}")
|
||||
with open(cat_path, encoding="utf-8") as fh:
|
||||
catalog = yaml.safe_load(fh)
|
||||
|
||||
method = scope.get("method") or catalog.get("method")
|
||||
if method not in METHODS:
|
||||
sys.exit(f"ERROR: unknown method '{method}' (have {list(METHODS)})")
|
||||
|
||||
q = METHODS[method].build(scope, rate, catalog)
|
||||
|
||||
# enrich with universal metadata
|
||||
prospect = scope.get("prospect", {})
|
||||
date = prospect.get("audit_date") or datetime.date.today().isoformat()
|
||||
d0 = datetime.date.fromisoformat(date)
|
||||
seq = args.seq if args.seq is not None else scope.get("seq", 1)
|
||||
q.update({
|
||||
"quote_no": f"{rate.get('quote_prefix', 'OD')}-{date[:4]}-{seq:03d}",
|
||||
"date": date,
|
||||
"valid_until": (d0 + datetime.timedelta(days=rate["terms"]["validity_days"])).isoformat(),
|
||||
"prospect": prospect, "company": rate["company"], "terms": rate["terms"],
|
||||
"disclaimer": DISCLAIMER[method].format(billing=int(q.get("billing_rate", rate["billing_rate"]) * 100)),
|
||||
})
|
||||
|
||||
render.write_all(q, args.out_dir)
|
||||
print(f"견적 {q['quote_no']} [{service}/{method}"
|
||||
+ (f"/{q['tier']}" if q['kind'] == 'effort' else "")
|
||||
+ f"] 제안가 {q['proposal']:,}원 (합계 {int(round(q['subtotal_sum'])):,}원)"
|
||||
+ (" ⚠STUB" if q.get("stub") else ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
# estimate-engine costing methods: effort, coaching, procurement.
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Coaching method: cost = Σ(lesson_type_price × hours) over a lesson plan.
|
||||
|
||||
Base lesson-type prices reproduce real quotes (GA4/GTM 중급 → ₩1,570,000).
|
||||
Optional matrix mode prices by subject level (base × level_multiple).
|
||||
Student-count discount is opt-in (real 1:1 quotes apply none).
|
||||
"""
|
||||
|
||||
|
||||
def _discount(rate, students, apply):
|
||||
if not apply:
|
||||
return 0.0
|
||||
for mx, r in rate["coaching"]["student_discount"]["bands"]:
|
||||
if students <= mx:
|
||||
return float(r)
|
||||
return 0.0 # 30+ → 별도 협의
|
||||
|
||||
|
||||
def _unit_price(rate, lesson, mode):
|
||||
c = rate["coaching"]
|
||||
base = c["lesson_type_prices"][lesson["type"]]
|
||||
if mode == "matrix":
|
||||
lvl = c.get("subject_levels", {}).get(lesson.get("subject"))
|
||||
if lvl:
|
||||
base = base * c["level_multiple"].get(lvl, 1.0)
|
||||
return base
|
||||
|
||||
|
||||
def build(scope, rate, catalog):
|
||||
c = rate["coaching"]
|
||||
mode = scope.get("pricing_mode") or c.get("pricing_mode", "base")
|
||||
if scope.get("lessons"):
|
||||
lessons = scope["lessons"]
|
||||
title = scope.get("course") or "맞춤 코칭"
|
||||
else:
|
||||
cname = scope.get("course")
|
||||
courses = catalog.get("courses", {})
|
||||
if cname not in courses:
|
||||
raise SystemExit(f"course '{cname}' not in catalog {list(courses)}")
|
||||
lessons = courses[cname]["lessons"]
|
||||
title = courses[cname].get("title", cname)
|
||||
|
||||
items, subtotal = [], 0.0
|
||||
for L in lessons:
|
||||
up = _unit_price(rate, L, mode)
|
||||
amt = up * L["hours"]
|
||||
items.append({"subject": L.get("subject", ""), "title": L.get("title", ""),
|
||||
"type": L["type"], "hours": L["hours"], "unit_price": up, "amount": amt})
|
||||
subtotal += amt
|
||||
|
||||
students = scope.get("students", 1)
|
||||
apply = scope.get("apply_student_discount", c["student_discount"]["apply_default"])
|
||||
disc = _discount(rate, students, apply)
|
||||
disc_amt = subtotal * disc
|
||||
total = subtotal - disc_amt
|
||||
return {
|
||||
"kind": "coaching", "service": catalog["service"], "label": title,
|
||||
"pricing_mode": mode, "students": students,
|
||||
"discount_rate": disc, "discount_amount": disc_amt,
|
||||
"lessons": items, "subtotal_sum": subtotal, "proposal": int(round(total)),
|
||||
"stub": bool(catalog.get("_stub", False)),
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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)),
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Procurement method: cost = Σ(unit_cost × qty × (1 + markup)) for non-labor items."""
|
||||
|
||||
|
||||
def build(scope, rate, catalog):
|
||||
markup = rate.get("procurement_markup", 0.15)
|
||||
items, total = [], 0.0
|
||||
for it in scope.get("items", []):
|
||||
qty = it.get("qty", 1)
|
||||
amt = it["unit_cost"] * qty * (1 + markup)
|
||||
items.append({"label": it["label"], "unit_cost": it["unit_cost"], "qty": qty,
|
||||
"markup": markup, "amount": amt, "currency": it.get("currency", "KRW")})
|
||||
total += amt
|
||||
return {
|
||||
"kind": "procurement", "service": catalog.get("service", "procurement"),
|
||||
"label": "조달 항목 (Buying & Supplying)", "markup": markup,
|
||||
"items": items, "subtotal_sum": total, "proposal": int(round(total)),
|
||||
"stub": bool(catalog.get("_stub", False)),
|
||||
}
|
||||
123
custom-skills/96-ourdigital-estimate-engine/scripts/render.py
Normal file
123
custom-skills/96-ourdigital-estimate-engine/scripts/render.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Render an enriched quote dict to 05_estimate_ko.md, 05_estimate.xlsx, data/estimate.json.
|
||||
Handles all kinds: effort (modules), coaching (lessons), procurement (items)."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
|
||||
ROLE_KO = {"ceo": "대표", "evp": "전무", "svp": "상무", "technical_advisor": "기술고문",
|
||||
"director": "이사", "senior_manager": "부장", "deputy_manager": "차장",
|
||||
"manager": "과장", "assistant_manager": "대리", "junior": "주임",
|
||||
"associate": "사원", "intern": "인턴"}
|
||||
NAVY = "11243D"
|
||||
|
||||
|
||||
def won(n):
|
||||
return f"{int(round(n)):,}원"
|
||||
|
||||
|
||||
def _header_md(q):
|
||||
svc = q.get("label", q["service"])
|
||||
if q["kind"] == "effort":
|
||||
svc += f" ({q['tier']})"
|
||||
L = [f"# 견적서 — {q['prospect'].get('name', '(prospect)')}", "",
|
||||
f"- **제공 서비스**: {svc}",
|
||||
f"- **견적번호**: {q['quote_no']} · **작성일**: {q['date']} · **유효기간**: ~{q['valid_until']}",
|
||||
f"- **공급자**: {q['company']['legal_name']} (대표 {q['company']['ceo']}, {q['company']['contact']})"]
|
||||
if q.get("stub"):
|
||||
L.append("- ⚠️ **STUB 카탈로그** — 실제 단가 미반영(placeholder). 실제 견적 자료로 교체 필요.")
|
||||
L.append("")
|
||||
return L
|
||||
|
||||
|
||||
def _md(q, path):
|
||||
L = _header_md(q)
|
||||
if q["kind"] == "effort":
|
||||
if q["scope"]["hours_multiplier"] != 1.0:
|
||||
dl = "브랜드/템플릿" if q["scope"]["driver"] == "subbrands_total" else "프로퍼티"
|
||||
L += [f"> 규모 반영: {dl} {q['scope']['driver_count']}개 기준 On-page 업무시간 ×{q['scope']['hours_multiplier']:g}", ""]
|
||||
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'])}** · 청구율 {int(q['billing_rate']*100)}% · 일8h/월4주",
|
||||
f"- **제안가(절사 적용): {won(q['proposal'])}** ({q['terms']['vat']})"]
|
||||
elif q["kind"] == "coaching":
|
||||
L += ["## 견적 내역", "", "| 구분 | 세부 | 방식 | 시간 | 단가 | 합계 |", "|---|---|:--:|--:|--:|--:|"]
|
||||
for it in q["lessons"]:
|
||||
L.append(f"| {it['subject']} | {it['title']} | {it['type']} | {it['hours']:g} | {won(it['unit_price'])} | {won(it['amount'])} |")
|
||||
L += ["", f"- 합계: **{won(q['subtotal_sum'])}** ({q['pricing_mode']} 단가, 수강생 {q['students']}명)"]
|
||||
if q["discount_rate"]:
|
||||
L.append(f"- 할인({int(q['discount_rate']*100)}%): -{won(q['discount_amount'])}")
|
||||
L.append(f"- **제안가: {won(q['proposal'])}** ({q['terms']['vat']})")
|
||||
elif q["kind"] == "procurement":
|
||||
L += ["## 조달 내역", "", "| 항목 | 단가 | 수량 | 수수료 | 합계 |", "|---|--:|--:|:--:|--:|"]
|
||||
for it in q["items"]:
|
||||
L.append(f"| {it['label']} | {it['unit_cost']:,}{it['currency']} | {it['qty']:g} | {int(it['markup']*100)}% | {won(it['amount'])} |")
|
||||
L += ["", f"- **합계: {won(q['proposal'])}** ({q['terms']['vat']})"]
|
||||
L += ["", "---", f"> {q['disclaimer']}"]
|
||||
if q["kind"] == "effort" and 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 _xlsx(q, path):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "견적"
|
||||
ws.append([f"견적서 — {q['prospect'].get('name', '(prospect)')} ({q.get('label', q['service'])})"])
|
||||
ws.append([f"견적번호 {q['quote_no']}", f"작성일 {q['date']}", f"유효 ~{q['valid_until']}", q["terms"]["vat"]])
|
||||
ws.append([])
|
||||
|
||||
def hdr(cols):
|
||||
ws.append(cols)
|
||||
for c in range(1, len(cols) + 1):
|
||||
cell = ws.cell(row=ws.max_row, column=c)
|
||||
cell.fill = PatternFill("solid", fgColor=NAVY)
|
||||
cell.font = Font(color="FFFFFF", bold=True)
|
||||
|
||||
if q["kind"] == "effort":
|
||||
hdr(["구분", "세부 업무", "담당", "시간(h)", "합계(원)"])
|
||||
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.append([])
|
||||
ws.append(["", "제안가(절사 적용)", "", "", int(q["proposal"])])
|
||||
widths = [22, 40, 8, 8, 16]
|
||||
elif q["kind"] == "coaching":
|
||||
hdr(["구분", "세부", "방식", "시간", "단가", "합계(원)"])
|
||||
for it in q["lessons"]:
|
||||
ws.append([it["subject"], it["title"], it["type"], it["hours"], int(it["unit_price"]), int(round(it["amount"]))])
|
||||
ws.append([])
|
||||
ws.append(["", "제안가", "", "", "", int(q["proposal"])])
|
||||
widths = [18, 34, 8, 6, 12, 14]
|
||||
else: # procurement
|
||||
hdr(["항목", "단가", "수량", "수수료", "합계(원)"])
|
||||
for it in q["items"]:
|
||||
ws.append([it["label"], it["unit_cost"], it["qty"], it["markup"], int(round(it["amount"]))])
|
||||
ws.append([])
|
||||
ws.append(["", "합계", "", "", int(q["proposal"])])
|
||||
widths = [34, 14, 8, 10, 16]
|
||||
ws.cell(row=ws.max_row, column=2).font = Font(bold=True)
|
||||
ws.cell(row=ws.max_row, column=len(widths)).font = Font(bold=True, color="C0392B")
|
||||
ws.append([])
|
||||
ws.append([q["disclaimer"]])
|
||||
for idx, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[chr(64 + idx)].width = w
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def write_all(q, out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
ddir = os.path.join(out_dir, "data")
|
||||
os.makedirs(ddir, exist_ok=True)
|
||||
with open(os.path.join(ddir, "estimate.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump(q, fh, ensure_ascii=False, indent=2)
|
||||
_md(q, os.path.join(out_dir, "05_estimate_ko.md"))
|
||||
_xlsx(q, os.path.join(out_dir, "05_estimate.xlsx"))
|
||||
Reference in New Issue
Block a user