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:
@@ -55,10 +55,12 @@ headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.jso
|
|||||||
→ write `03_presales-opportunity-brief.md`.
|
→ write `03_presales-opportunity-brief.md`.
|
||||||
|
|
||||||
## Stage 5 — Estimate (견적) — REVIEW GATE
|
## Stage 5 — Estimate (견적) — REVIEW GATE
|
||||||
- `python scripts/estimate.py --findings <out>/data/findings.json --rate-card references/rate_card.yaml --sow references/sow_templates.yaml --out-dir <out> --seq <N> [--baseline basic|treatment] [--billing 0.70]`
|
Pricing is delegated to the **`ourdigital-estimate-engine`** skill (`../96-ourdigital-estimate-engine`); this skill only maps findings → scope, then calls the engine:
|
||||||
- **Effort-based** (OurDigital real model): cost = role_rate × 청구율 × 표준 업무시간, by module; 제안가 = 합계 절사. findings auto-select a tier (**smb / basic / treatment**) and scale **On-page** hours sub-linearly by `subbrands_total` (cap ×2.0); Technical is fixed. `smb` tier bills at 0.55, others 0.70. Override with `--baseline` / `--billing`. Premium verticals (luxury/5성/특1급…) are auto-floored to `basic` via `rate_card.tiering` (so a luxury single property won't drop to `smb`).
|
- `python scripts/findings_to_scope.py --findings <out>/data/findings.json --out <out>/data/scope.json --seq <N> [--tier auto|smb|basic|treatment] [--billing 0.70]`
|
||||||
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. Present the 견적; get sign-off.
|
- `ENG=../96-ourdigital-estimate-engine; python $ENG/scripts/estimate.py --rate-card $ENG/references/rate_card.yaml --catalog-dir $ENG/catalog --scope <out>/data/scope.json --out-dir <out>`
|
||||||
- Logic in `references/findings_to_service.md`; rates/hours in `rate_card.yaml` + `sow_templates.yaml` (edit together). Reproduces real Basic ₩10.5M / Treatment ₩25.0M; SMB entry ~₩3M; chains stay SMB-acceptable (e.g. 25-property → ~₩29.5M).
|
- Engine (effort method, `seo` catalog): auto-tier (smb/basic/treatment) by portfolio + premium-vertical floor; On-page hours scale by `subbrands_total` (cap ×2.0); 제안가 = 합계 절사. Reproduces Basic ₩10.5M / Treatment ₩25.0M; SMB ~₩3M; 25-property chain ~₩29.5M.
|
||||||
|
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json` (effort shape → consumed by `build_deck.py`). Present the 견적; get sign-off.
|
||||||
|
- SEO findings→scope/tier mapping lives here (`findings_to_service.md`); **rates/hours/tiers live in the engine** (`rate_card.yaml` + `catalog/seo.yaml`) — edit pricing there, not here.
|
||||||
|
|
||||||
## Stage 6 — Deliverables — REVIEW GATE before send
|
## Stage 6 — Deliverables — REVIEW GATE before send
|
||||||
- **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS),
|
- **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS),
|
||||||
|
|||||||
@@ -44,5 +44,8 @@ markup if billed through us. VAT 별도 · 유효기간 14d · 현금 · 절사
|
|||||||
- `smb` single property → ~**3,000,000** (lean × 55% billing)
|
- `smb` single property → ~**3,000,000** (lean × 55% billing)
|
||||||
- chain example (SHR, 5 sub-brands ×1.6, treatment) → ~**29,500,000** (vs naive 71.5M)
|
- chain example (SHR, 5 sub-brands ×1.6, treatment) → ~**29,500,000** (vs naive 71.5M)
|
||||||
|
|
||||||
Edit `rate_card.yaml` + `sow_templates.yaml` together when rates, hours, tiers, or
|
**Pricing now lives in the `ourdigital-estimate-engine` skill** (`../96-ourdigital-estimate-engine`):
|
||||||
scaling change.
|
role rates/billing/tiering/scaling in `references/rate_card.yaml`, SEO tiers/hours in
|
||||||
|
`catalog/seo.yaml`. This skill maps findings → `scope.json` (`scripts/findings_to_scope.py`)
|
||||||
|
and calls the engine. Edit rates/hours/tiers **in the engine**, not here. This file documents
|
||||||
|
only the SEO findings→tier/scope mapping.
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
# OurDigital / D.intelligence — rate card (effort-based)
|
|
||||||
# Source of truth: 06_Working Template 견적서 (Google Drive), 2026-05.
|
|
||||||
# Model: 비용 = 시간단가(role) × 청구율(billing_rate) × 표준 업무시간(hours).
|
|
||||||
# Estimates are assembled bottom-up from sow_templates.yaml. Keep PRIVATE.
|
|
||||||
|
|
||||||
company:
|
|
||||||
legal_name: "(주)디인텔리전스 (D.intelligence Lab)"
|
|
||||||
brand: "OurDigital"
|
|
||||||
ceo: "임명재"
|
|
||||||
contact: "info@ourdigital.org"
|
|
||||||
address: "경기도 성남시 수정구 창업로 43 판교글로벌비즈센터 업무동 1층 128호"
|
|
||||||
|
|
||||||
quote_prefix: OD # OD-YYYY-NNN
|
|
||||||
currency: KRW
|
|
||||||
|
|
||||||
billing_rate: 0.70 # 청구율 표준 70%
|
|
||||||
billing_rate_floor: 0.60 # 협상 시 -10%p 까지(>10%p 조정은 CEO/CFO 승인)
|
|
||||||
basis:
|
|
||||||
hours_per_day: 8 # 일 8시간
|
|
||||||
weeks_per_month: 4 # 월 4주
|
|
||||||
procurement_markup: 0.15 # 조달-관리 수수료 15% (외부 조달 물품/서비스)
|
|
||||||
rounding_unit: 500000 # 제안가 = 합계 절사. 50만원 단위가 실제 견적(25,340,000→25,000,000;
|
|
||||||
# 10,612,000→10,500,000)을 재현. 필요시 100000/1000000 으로 조정.
|
|
||||||
|
|
||||||
terms:
|
|
||||||
vat: "부가세 별도"
|
|
||||||
validity_days: 14 # 견적 유효기간(약 2주)
|
|
||||||
payment: "현금"
|
|
||||||
|
|
||||||
# 자동 티어링 보정. 프리미엄/럭셔리 prospect 는 규모가 작아도 entry(smb) 아래로
|
|
||||||
# 자동 선택되지 않도록 최소 티어를 보장. prospect.vertical 문자열에 아래 용어가
|
|
||||||
# 포함되면 프리미엄으로 간주(부분 일치, 대소문자 무시).
|
|
||||||
tiering:
|
|
||||||
premium_verticals: ["luxury", "premium", "deluxe", "5_star", "5성", "특1급", "boutique_luxury"]
|
|
||||||
premium_min_tier: basic # 프리미엄 단일 프로퍼티 최소 티어 (smb < basic < treatment)
|
|
||||||
|
|
||||||
# 시간 단가 (KRW/hr) — 직급 기준. sow_templates.yaml의 task.role 이 이 키를 참조.
|
|
||||||
role_rates:
|
|
||||||
ceo: 180000 # 대표
|
|
||||||
evp: 150000 # 전무
|
|
||||||
svp: 120000 # 상무
|
|
||||||
technical_advisor: 120000 # 기술고문
|
|
||||||
director: 100000 # 이사 / 본부장
|
|
||||||
senior_manager: 90000 # 부장 (Senior SEO·PM·Senior Analyst·Account Director(국장))
|
|
||||||
deputy_manager: 80000 # 차장
|
|
||||||
manager: 70000 # 과장 (SEO·Data Analyst·Data Engineer·Engineer|Developer)
|
|
||||||
assistant_manager: 60000 # 대리
|
|
||||||
junior: 50000 # 주임
|
|
||||||
associate: 30000 # 사원
|
|
||||||
intern: 12000 # 인턴
|
|
||||||
|
|
||||||
# 포트폴리오 규모에 따른 '시간' 스케일(서브선형). On-page(scale:true) task 에만 적용.
|
|
||||||
# driver = findings.entity 의 카운트. 체인은 페이지 템플릿을 공유하므로 '프로퍼티 수'가 아닌
|
|
||||||
# '브랜드/템플릿 수(subbrands_total)'를 기준으로 하고, 캡을 ×2.0 로 낮게 둔다.
|
|
||||||
# (Technical SEO 는 사이트 단위 고정 작업이므로 스케일하지 않음 — sow_templates 의 scale:false)
|
|
||||||
scaling:
|
|
||||||
driver: subbrands_total
|
|
||||||
bands: [[1, 1.0], [3, 1.3], [6, 1.6], [999999, 2.0]]
|
|
||||||
|
|
||||||
# 별도 조달 항목(인력비와 분리). 청구 시 procurement_markup 적용 가능.
|
|
||||||
tools:
|
|
||||||
semrush_guru:
|
|
||||||
label: "Advanced SEO Tools — SEMrush Guru"
|
|
||||||
unit: "월간 구독"
|
|
||||||
price_usd: 249.95
|
|
||||||
note: "고객사 별도 구독(TBD), 사용자별 과금"
|
|
||||||
@@ -259,7 +259,7 @@ def main():
|
|||||||
tbl = s.shapes.add_table(rows, 2, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.5 * rows)).table
|
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[0].width = Inches(8.4)
|
||||||
tbl.columns[1].width = Inches(3.2)
|
tbl.columns[1].width = Inches(3.2)
|
||||||
for j, htxt in enumerate([f"{EST.get('service', 'SEO')} — 구분", "소계"]):
|
for j, htxt in enumerate([f"{EST.get('label') or EST.get('service', 'SEO')} — 구분", "소계"]):
|
||||||
c = tbl.cell(0, j)
|
c = tbl.cell(0, j)
|
||||||
c.text = htxt
|
c.text = htxt
|
||||||
c.fill.solid()
|
c.fill.solid()
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Map an SEO findings.json → estimate-engine scope.json (service=seo, effort).
|
||||||
|
|
||||||
|
Stage 5 of ourdigital-presales-seo: keeps the SEO-specific mapping here; the engine
|
||||||
|
(96-ourdigital-estimate-engine) owns the costing. Then call:
|
||||||
|
python <engine>/scripts/estimate.py --rate-card <engine>/references/rate_card.yaml \
|
||||||
|
--catalog-dir <engine>/catalog --scope scope.json --out-dir <engagement>
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python findings_to_scope.py --findings data/findings.json --out data/scope.json [--tier auto] [--seq N]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="SEO findings.json -> engine scope.json")
|
||||||
|
ap.add_argument("--findings", required=True)
|
||||||
|
ap.add_argument("--out", required=True)
|
||||||
|
ap.add_argument("--tier", default="auto", help="auto|smb|basic|treatment")
|
||||||
|
ap.add_argument("--billing", type=float, default=None)
|
||||||
|
ap.add_argument("--seq", type=int, default=1)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
with open(args.findings, encoding="utf-8") as fh:
|
||||||
|
f = json.load(fh)
|
||||||
|
e = f.get("entity", {})
|
||||||
|
p = f.get("prospect", {})
|
||||||
|
severity = sorted({x.get("severity") for x in f.get("findings", []) if x.get("severity")})
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"service": "seo",
|
||||||
|
"method": "effort",
|
||||||
|
"tier": args.tier,
|
||||||
|
"billing_rate": args.billing,
|
||||||
|
"signals": {
|
||||||
|
"properties_total": e.get("properties_total", 0),
|
||||||
|
"subbrands_total": e.get("subbrands_total", 0),
|
||||||
|
"vertical": p.get("vertical", ""),
|
||||||
|
"severity": severity,
|
||||||
|
},
|
||||||
|
"prospect": {
|
||||||
|
"name": p.get("name", ""), "domain": p.get("domain", ""),
|
||||||
|
"audit_date": p.get("audit_date", ""), "account_code": p.get("account_code", ""),
|
||||||
|
},
|
||||||
|
"seq": args.seq,
|
||||||
|
}
|
||||||
|
with open(args.out, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(scope, fh, ensure_ascii=False, indent=2)
|
||||||
|
print(f"scope -> {args.out} | service=seo tier={args.tier} "
|
||||||
|
f"properties={scope['signals']['properties_total']} subbrands={scope['signals']['subbrands_total']} "
|
||||||
|
f"vertical={scope['signals']['vertical']!r}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
120
custom-skills/96-ourdigital-estimate-engine/DESIGN.md
Normal file
120
custom-skills/96-ourdigital-estimate-engine/DESIGN.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Design — `ourdigital-estimate-engine` skill
|
||||||
|
|
||||||
|
- **Status**: Approved design (2026-05-28). Implementing.
|
||||||
|
- **Origin**: Extracted from `ourdigital-presales-seo`'s estimate engine and generalized to
|
||||||
|
cover all OurDigital / D.intelligence professional services via multiple costing methods.
|
||||||
|
- **Source data**: real quotes in Google Drive `06_Working Template` (SEO) and `견적 자료`
|
||||||
|
(GA4/GTM, education). Company: (주)디인텔리전스 / D.intelligence Lab, info@ourdigital.org.
|
||||||
|
|
||||||
|
## 1. Purpose & scope
|
||||||
|
A reusable, **method-aware** estimate engine that produces OurDigital 견적 for any service
|
||||||
|
line. Consuming skills (e.g. `ourdigital-presales-seo`) map their context → a generic
|
||||||
|
`scope.json` and call the engine CLI. The engine owns the rate card + service catalog
|
||||||
|
(single source of truth).
|
||||||
|
|
||||||
|
### Costing methods (from real data)
|
||||||
|
1. **effort** — `role_rate × billing_rate(0.70) × hours`, grouped into modules; tiering,
|
||||||
|
sub-brand scaling, premium-vertical floor, 절사. (SEO Audit & Treatment, etc.)
|
||||||
|
2. **coaching** — `Σ(lesson_type_price × hours)` over a lesson plan, with student-count
|
||||||
|
discount. Base type prices reproduce real quotes; optional subject×level list-price
|
||||||
|
matrix (`base × level_multiple`). (GA4/GTM courses, workshops, education.)
|
||||||
|
3. **procurement** — `unit_cost × qty × (1 + 0.15)` for non-labor items (tools, 3rd-party).
|
||||||
|
|
||||||
|
All methods share: cover-sheet output, 부가세 별도, 유효기간 14d, 현금, 십만 단위 미만 절사
|
||||||
|
(500k floor for project quotes), quote no `OD-YYYY-NNN`.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
- No invented rates. `digital_ads` / `digital_branding` ship as **effort stubs** (clearly
|
||||||
|
marked) until real quotes exist.
|
||||||
|
- No GA4/GTM *effort-implementation* catalog (delivered as coaching → a course in education).
|
||||||
|
- Does not replace consuming skills' domain logic (e.g. SEO findings→scope mapping stays in
|
||||||
|
presales-seo).
|
||||||
|
|
||||||
|
## 2. Structure
|
||||||
|
```
|
||||||
|
96-ourdigital-estimate-engine/
|
||||||
|
SKILL.md
|
||||||
|
references/
|
||||||
|
rate_card.yaml # UNIVERSAL pricing config (see §3)
|
||||||
|
catalog/
|
||||||
|
seo.yaml # method: effort — tiers smb/basic/treatment (real)
|
||||||
|
education.yaml # method: coaching — courses + per-subject levels (real)
|
||||||
|
digital_ads.yaml # method: effort — STUB
|
||||||
|
digital_branding.yaml # method: effort — STUB
|
||||||
|
scripts/
|
||||||
|
estimate.py # CLI dispatcher
|
||||||
|
methods/effort.py
|
||||||
|
methods/coaching.py
|
||||||
|
methods/procurement.py
|
||||||
|
render.py # md / xlsx / json output (cover-sheet)
|
||||||
|
scope.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. `rate_card.yaml` (universal)
|
||||||
|
- `company`, `quote_prefix: OD`, `currency: KRW`, `rounding_unit: 500000`
|
||||||
|
- `terms`: vat 부가세 별도, validity_days 14, payment 현금
|
||||||
|
- **effort**: `billing_rate: 0.70` (floor 0.60), `basis {8h/day, 4wk/mo}`,
|
||||||
|
`procurement_markup: 0.15`, `role_rates {대표 180k … 과장 70k … 인턴 12k}`,
|
||||||
|
`tiering {premium_verticals, premium_min_tier}`, `scaling {driver: subbrands_total,
|
||||||
|
bands cap ×2.0}`, `tools`.
|
||||||
|
- **coaching**:
|
||||||
|
- `lesson_type_prices`: 화상 80,000 · 대면 100,000 · 실습 150,000 · 워크숍 300,000 · 트리트먼트 500,000
|
||||||
|
- `level_multiple`: Beginners 1.0 · Intermediate 1.5 · Advanced 2.0 · Expert 2.5 · Trainers 3.0
|
||||||
|
- `subject_levels`: {Content Marketing: Beginners, SEO: Beginners, Google Analytics: Intermediate,
|
||||||
|
Google Tag Manager: Advanced, Digital Marketing Strategy: Advanced, Digital Communication:
|
||||||
|
Intermediate, KPI Setup & Measurement Plan: Expert, Business Model Canvas: Expert,
|
||||||
|
e-Commerce Audit: Advanced}
|
||||||
|
- `pricing_mode: base` (default; reproduces real quotes) | `matrix` (list price = base×level_multiple)
|
||||||
|
- `student_discount_bands`: [[5,0.20],[10,0.0],[20,0.20],[30,0.30]] (30+ = 별도 협의)
|
||||||
|
|
||||||
|
## 4. Catalog entries
|
||||||
|
Each file: `service`, `method`, and method-specific body.
|
||||||
|
- **seo.yaml** (effort): the current `sow_templates.yaml` verbatim (tiers smb/basic/treatment,
|
||||||
|
modules→tasks with role/hours/scale). Validated to reproduce 10.5M/25.0M.
|
||||||
|
- **education.yaml** (coaching): `courses:` — named lesson plans, each `lessons: [{subject,
|
||||||
|
type, hours}]`. Seed with `ga4_gtm_intermediate` (the real 17-lesson, ₩1,570,000 plan) and
|
||||||
|
`ga4_gtm_marketing_analytics`. Subjects resolve levels from rate_card.subject_levels.
|
||||||
|
- **digital_ads.yaml / digital_branding.yaml** (effort): STUB — one placeholder tier +
|
||||||
|
`_stub: true`, with a header comment to populate from a real quote.
|
||||||
|
|
||||||
|
## 5. `scope.json` (generic input — `scope.schema.json`)
|
||||||
|
```jsonc
|
||||||
|
// effort
|
||||||
|
{"service":"seo","method":"effort","tier":"auto", // or smb|basic|treatment
|
||||||
|
"signals":{"properties_total":25,"subbrands_total":5,"vertical":"hotel_resort"},
|
||||||
|
"billing_rate":null, "seq":1, "prospect":{"name":"","audit_date":""}}
|
||||||
|
// coaching
|
||||||
|
{"service":"education","method":"coaching","course":"ga4_gtm_intermediate",
|
||||||
|
"students":1, "pricing_mode":"base", "prospect":{...}} // or "lessons":[{subject,type,hours}]
|
||||||
|
```
|
||||||
|
`tier:"auto"` → engine runs tiering (size + premium floor) from `signals`.
|
||||||
|
|
||||||
|
## 6. CLI
|
||||||
|
`python scripts/estimate.py --catalog-dir catalog --rate-card references/rate_card.yaml
|
||||||
|
--scope scope.json --out-dir <dir> [--seq N]`
|
||||||
|
- Dispatcher loads scope → catalog entry → method module → `render.py`.
|
||||||
|
- Outputs `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`.
|
||||||
|
- **effort `estimate.json` keeps the current shape** (modules + 제안가 + scope + terms) so
|
||||||
|
`ourdigital-presales-seo/build_deck.py` keeps working unchanged.
|
||||||
|
|
||||||
|
## 7. `ourdigital-presales-seo` refactor (consumption = CLI)
|
||||||
|
- **Move** `references/rate_card.yaml` + `references/sow_templates.yaml` → engine
|
||||||
|
(`catalog/seo.yaml`). Delete `scripts/estimate.py` from presales-seo.
|
||||||
|
- **Add** `scripts/findings_to_scope.py` (thin): findings.json → scope.json (tier signals,
|
||||||
|
vertical, prospect). Keeps SEO-specific mapping out of the engine.
|
||||||
|
- **Stage 5** in SKILL.md: `findings_to_scope.py` → call engine
|
||||||
|
`estimate.py --service seo --scope scope.json`.
|
||||||
|
- `build_deck.py`, `kg_query.py`, `render_pdf.sh`, `findings_to_service.md`,
|
||||||
|
`findings.schema.json`, templates → unchanged.
|
||||||
|
|
||||||
|
## 8. Validation (before commit)
|
||||||
|
- effort: SEO basic ₩10.5M, treatment ₩25.0M (1 property); SHR ₩29.5M; L'Escape `basic` ₩10.5M
|
||||||
|
— identical to pre-refactor.
|
||||||
|
- coaching: `ga4_gtm_intermediate` (17 lessons, 1 student) → **₩1,570,000** exactly.
|
||||||
|
- procurement: unit_cost × qty × 1.15 sanity check.
|
||||||
|
- presales-seo end-to-end: findings→scope→engine→견적 + deck reproduces SHR/L'Escape.
|
||||||
|
|
||||||
|
## 9. Future
|
||||||
|
Populate `digital_ads` / `digital_branding` from real quotes; add `content_marketing` as a
|
||||||
|
project-service if effort quotes emerge (currently a coaching subject); optional matrix-mode
|
||||||
|
quotes; more education courses.
|
||||||
55
custom-skills/96-ourdigital-estimate-engine/SKILL.md
Normal file
55
custom-skills/96-ourdigital-estimate-engine/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: ourdigital-estimate-engine
|
||||||
|
description: Method-aware estimate/견적 engine for OurDigital / D.intelligence professional services (SEO, GA4/GTM, education/coaching, digital ads, branding, …). Generates a Korean 견적 (md/xlsx/json) from a generic scope.json using the real company rate card. Use for "OurDigital 견적", "estimate", "quote", "proposal pricing", "cost estimate", "견적서 생성", or when another skill needs to price a service. Costing methods: effort (role×billing×hours), coaching (lesson×hours), procurement (+15%).
|
||||||
|
---
|
||||||
|
|
||||||
|
# OurDigital Estimate Engine
|
||||||
|
|
||||||
|
Single source of truth for OurDigital/D.intelligence pricing. Consuming skills map their
|
||||||
|
context into a `scope.json` and call the engine CLI; the engine owns the rate card + service
|
||||||
|
catalog and renders the 견적.
|
||||||
|
|
||||||
|
## Costing methods (catalog entry declares its `method`)
|
||||||
|
- **effort** — `role_rate × 청구율(0.70) × 표준 업무시간`, by module; auto-tier (smb/basic/treatment)
|
||||||
|
by portfolio size + premium-vertical floor; On-page hours scale by sub-brands (cap ×2.0);
|
||||||
|
제안가 = 합계 절사. Reproduces real SEO quotes (basic ₩10.5M / treatment ₩25.0M).
|
||||||
|
- **coaching** — `Σ(lesson_type_price × hours)` over a lesson plan; student discount opt-in.
|
||||||
|
Reproduces real GA4/GTM 코칭 (₩1,570,000). Base prices default; subject×level matrix optional.
|
||||||
|
- **procurement** — `unit_cost × qty × (1 + 0.15)` for non-labor items.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `references/rate_card.yaml` — universal: role rates, billing, basis, terms, tiering, scaling,
|
||||||
|
procurement, coaching prices/levels/discounts, tools. **Private.**
|
||||||
|
- `catalog/<service>.yaml` — per-service: `service`, `method`, body. Real: `seo` (effort),
|
||||||
|
`education` (coaching). Stubs: `digital_ads`, `digital_branding` (effort — `_stub: true`).
|
||||||
|
- `scripts/estimate.py` — CLI dispatcher; `scripts/methods/{effort,coaching,procurement}.py`;
|
||||||
|
`scripts/render.py` (md/xlsx/json). `scope.schema.json` — input contract.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```
|
||||||
|
python scripts/estimate.py \
|
||||||
|
--rate-card references/rate_card.yaml --catalog-dir catalog \
|
||||||
|
--scope scope.json --out-dir <engagement> [--seq N]
|
||||||
|
```
|
||||||
|
`scope.json` examples (see `scope.schema.json`):
|
||||||
|
- effort: `{"service":"seo","tier":"auto","signals":{"properties_total":25,"subbrands_total":5,"vertical":"hotel_resort"},"prospect":{"name":"…","audit_date":"YYYY-MM-DD"}}`
|
||||||
|
- coaching: `{"service":"education","course":"ga4_gtm_intermediate","students":1,"prospect":{…}}`
|
||||||
|
- procurement: `{"service":"seo","method":"procurement","items":[{"label":"SEMrush Guru","unit_cost":330000,"qty":6}]}`
|
||||||
|
|
||||||
|
Outputs `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. The effort `estimate.json`
|
||||||
|
shape is consumed by `ourdigital-presales-seo/build_deck.py`.
|
||||||
|
|
||||||
|
## Consuming from another skill (CLI)
|
||||||
|
1. Map your context → `scope.json` (service, tier signals or lessons, prospect).
|
||||||
|
2. Call `estimate.py`. 3. Use the rendered 견적; for SEO, feed `data/estimate.json` to the deck.
|
||||||
|
Example consumer: `ourdigital-presales-seo` (`findings_to_scope.py` → engine → 견적 + deck).
|
||||||
|
|
||||||
|
## Adding / editing services
|
||||||
|
- New real service: add `catalog/<service>.yaml` with `method` + body from a **real quote**.
|
||||||
|
- Stubs (`digital_ads`, `digital_branding`) carry `_stub: true` and placeholder hours — replace
|
||||||
|
with real quote data before client use (the 견적 prints a ⚠STUB banner).
|
||||||
|
- Rates change in `rate_card.yaml` only (single source). Validate against a known real quote.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
Korean-first output · 부가세 별도 · 유효기간 14d · 현금 · `OD-YYYY-NNN`. Don't invent rates —
|
||||||
|
stub and flag instead. Legal entity (주)디인텔리전스 / info@ourdigital.org.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Digital Ads catalog — method: effort. STUB — placeholder hours, NOT real rates.
|
||||||
|
# TODO: populate tiers/modules/hours from a real OurDigital digital-ads quote (Drive),
|
||||||
|
# the same way seo.yaml was built. Until then estimates here are illustrative only.
|
||||||
|
service: digital_ads
|
||||||
|
method: effort
|
||||||
|
_stub: true
|
||||||
|
tiers:
|
||||||
|
basic:
|
||||||
|
label: "Digital Ads Setup & Management (STUB)"
|
||||||
|
modules:
|
||||||
|
- name: "Planning & Management"
|
||||||
|
tasks:
|
||||||
|
- {task: "캠페인 전략·계정 구조 설계", desc: "STUB — replace with real SOW", role: senior_manager, hours: 16, scale: false}
|
||||||
|
- name: "Ads Operation"
|
||||||
|
tasks:
|
||||||
|
- {task: "캠페인 셋업·태깅·전환 추적", desc: "STUB", role: manager, hours: 24, scale: false}
|
||||||
|
- {task: "운영·최적화·리포팅", desc: "STUB (월 운영)", role: manager, hours: 24, scale: true}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Digital Branding catalog — method: effort. STUB — placeholder hours, NOT real rates.
|
||||||
|
# TODO: populate from a real OurDigital digital-branding quote (Drive). Illustrative only.
|
||||||
|
service: digital_branding
|
||||||
|
method: effort
|
||||||
|
_stub: true
|
||||||
|
tiers:
|
||||||
|
basic:
|
||||||
|
label: "Digital Branding (STUB)"
|
||||||
|
modules:
|
||||||
|
- name: "Planning & Management"
|
||||||
|
tasks:
|
||||||
|
- {task: "브랜드 전략·포지셔닝", desc: "STUB — replace with real SOW", role: director, hours: 16, scale: false}
|
||||||
|
- name: "Branding Execution"
|
||||||
|
tasks:
|
||||||
|
- {task: "브랜드 아이덴티티·가이드", desc: "STUB", role: senior_manager, hours: 24, scale: false}
|
||||||
|
- {task: "디지털 자산·채널 적용", desc: "STUB", role: manager, hours: 24, scale: true}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Education / coaching catalog — method: coaching.
|
||||||
|
# Cost = Σ(lesson_type_prices[type] × hours) from rate_card.coaching (pricing_mode: base).
|
||||||
|
# Subjects resolve levels from rate_card.coaching.subject_levels (used only in matrix mode).
|
||||||
|
service: education
|
||||||
|
method: coaching
|
||||||
|
courses:
|
||||||
|
|
||||||
|
# Real quote: GA4/GTM 중급 과정 1:1 코칭 → ₩1,570,000 (7 대면×100k + 9 화상×80k + 1 실습×150k).
|
||||||
|
ga4_gtm_intermediate:
|
||||||
|
title: "GA4/GTM 중급 과정 1:1 코칭"
|
||||||
|
lessons:
|
||||||
|
- {subject: "Google Analytics", title: "설치-설정 진단", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "측정 계획 수립", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "기본 리포트 설정", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "맞춤 리포트 구성", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "획득 보고서의 이해", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "참여도 보고서 해석", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "수익창출 보고서 관리", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "주요 이벤트와 전환", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "탐색 분석 활용", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "이벤트 태깅 관리 준비", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "GTM 설정 분석과 검수", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "이벤트 태깅 실습", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "마케팅 태그 설정", type: 대면, hours: 1}
|
||||||
|
- {subject: "Google Tag Manager", title: "e-Commerce 태그 설정", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "잠재 고객 설정과 활용", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "캠페인 성과 분석", type: 화상, hours: 1}
|
||||||
|
- {subject: "Google Analytics", title: "Looker Studio 대시보드", type: 실습, hours: 1}
|
||||||
|
|
||||||
|
# Add more courses (workshops, SEO/Content Marketing coaching, etc.) as real lesson plans arrive.
|
||||||
|
# For an ad-hoc plan, pass scope.lessons directly instead of a named course.
|
||||||
@@ -1,90 +1,73 @@
|
|||||||
# SOW task templates — standard 업무 시간(hours) by module.
|
# SEO service catalog — method: effort. Tiers priced via rate_card (role×billing×hours).
|
||||||
# Priced via rate_card.yaml: cost = role_rate × billing_rate × hours.
|
# Reproduces real quotes: basic ₩10.5M, treatment ₩25.0M (1 property, billing 0.70).
|
||||||
# Tiers (제안가 at default billing): smb < basic < treatment.
|
# Only On-page tasks (scale:true) scale by sub-brands; Technical/P&M/Growth fixed.
|
||||||
# basic -> ₩10,500,000 (real SEO Basic & Coaching quote, billing 0.70)
|
service: seo
|
||||||
# treatment -> ₩25,000,000 (real SEO Audit & Treatment quote, billing 0.70)
|
method: effort
|
||||||
# smb -> lean entry tier for single-property SMBs (billing 0.55)
|
tiers:
|
||||||
# Scaling: only On-page tasks (scale:true) scale by sub-brands/templates
|
|
||||||
# (rate_card.scaling); Technical SEO is fixed site-wide work. P&M & Growth fixed.
|
|
||||||
# Each baseline may override billing_rate; else rate_card.billing_rate (0.70).
|
|
||||||
|
|
||||||
baselines:
|
|
||||||
|
|
||||||
smb:
|
smb:
|
||||||
service: "SEO Quick Audit (SMB)"
|
label: "SEO Quick Audit (SMB)"
|
||||||
billing_rate: 0.55 # SMB 진입 티어 — 낮은 청구율
|
billing_rate: 0.55
|
||||||
modules:
|
modules:
|
||||||
- name: "Planning & Management"
|
- name: "Planning & Management"
|
||||||
trigger: [always]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "업무 관리·착수", desc: "과업 정의·일정·리포팅", role: senior_manager, hours: 8, scale: false}
|
- {task: "업무 관리·착수", desc: "과업 정의·일정·리포팅", role: senior_manager, hours: 8, scale: false}
|
||||||
- {task: "웹 사이트 분석", desc: "유입·행동·전환 flow 요약 분석", role: senior_manager, hours: 8, scale: false}
|
- {task: "웹 사이트 분석", desc: "유입·행동·전환 flow 요약 분석", role: senior_manager, hours: 8, scale: false}
|
||||||
- name: "Technical SEO"
|
- name: "Technical SEO"
|
||||||
trigger: [crawlability, cwv, schema_entity]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "Crawling·Indexing 점검", desc: "검색사이트 등록·수집·Site Health 점검", role: manager, hours: 8, scale: false}
|
- {task: "Crawling·Indexing 점검", desc: "검색사이트 등록·수집·Site Health 점검", role: manager, hours: 8, scale: false}
|
||||||
- {task: "속도·CWV 점검", desc: "로딩속도·Core Web Vitals·모바일 점검", role: manager, hours: 8, scale: false}
|
- {task: "속도·CWV 점검", desc: "로딩속도·Core Web Vitals·모바일 점검", role: manager, hours: 8, scale: false}
|
||||||
- {task: "구조·메타·사이트맵 점검", desc: "사이트/URL 구조·메타·사이트맵·색인", role: manager, hours: 8, scale: false}
|
- {task: "구조·메타·사이트맵 점검", desc: "사이트/URL 구조·메타·사이트맵·색인", role: manager, hours: 8, scale: false}
|
||||||
- name: "On-page SEO"
|
- name: "On-page SEO"
|
||||||
trigger: [onpage, schema_entity]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "키워드·메타 진단", desc: "중점 키워드·페이지 메타·템플릿 진단", role: manager, hours: 12, scale: true}
|
- {task: "키워드·메타 진단", desc: "중점 키워드·페이지 메타·템플릿 진단", role: manager, hours: 12, scale: true}
|
||||||
- {task: "on-page 퀵윈 가이드", desc: "타이틀·메타·헤더·링크·이미지 핵심 개선 가이드", role: manager, hours: 16, scale: true}
|
- {task: "on-page 퀵윈 가이드", desc: "타이틀·메타·헤더·링크·이미지 핵심 개선 가이드", role: manager, hours: 16, scale: true}
|
||||||
- name: "SEO Growth"
|
- name: "SEO Growth"
|
||||||
trigger: [measurement, always]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "기본 성과 지표 설정", desc: "핵심 SEO 지표·중점 키워드 트래킹 설정", role: manager, hours: 8, scale: false}
|
- {task: "기본 성과 지표 설정", desc: "핵심 SEO 지표·중점 키워드 트래킹 설정", role: manager, hours: 8, scale: false}
|
||||||
|
|
||||||
basic:
|
basic:
|
||||||
service: "SEO Audit & Basic Treatment"
|
label: "SEO Audit & Basic Treatment"
|
||||||
modules:
|
modules:
|
||||||
- name: "Planning & Management"
|
- name: "Planning & Management"
|
||||||
trigger: [always]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "업무 관리", desc: "프로젝트 설계·과업 정의·일정/산출물 관리·리포팅", role: senior_manager, hours: 20, scale: false}
|
- {task: "업무 관리", desc: "프로젝트 설계·과업 정의·일정/산출물 관리·리포팅", role: senior_manager, hours: 20, scale: false}
|
||||||
- {task: "웹 사이트 분석", desc: "사용자 유입·채널 내 행동·전환 flow 분석", role: senior_manager, hours: 12, scale: false}
|
- {task: "웹 사이트 분석", desc: "사용자 유입·채널 내 행동·전환 flow 분석", role: senior_manager, hours: 12, scale: false}
|
||||||
- name: "Technical SEO"
|
- name: "Technical SEO"
|
||||||
trigger: [crawlability, cwv, schema_entity]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: technical_advisor, hours: 16, scale: false}
|
- {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리·Site Health Check 도구 설정", role: technical_advisor, hours: 16, scale: false}
|
||||||
- {task: "속도·UX·수집 설정", desc: "로딩속도·페이지 UX·링크·수집 제외 설정", role: manager, hours: 16, scale: false}
|
- {task: "속도·UX·수집 설정", desc: "로딩속도·페이지 UX·링크·수집 제외 설정", role: manager, hours: 16, scale: false}
|
||||||
- {task: "사이트/URL 구조·메타", desc: "구조·URL·메타데이터·사이트맵·리다이렉션", role: senior_manager, hours: 12, scale: false}
|
- {task: "사이트/URL 구조·메타", desc: "구조·URL·메타데이터·사이트맵·리다이렉션", role: senior_manager, hours: 12, scale: false}
|
||||||
- {task: "색인·CWV 진단", desc: "GSC·SEO Tools 활용 색인/크롤오류/Core Web Vitals 진단", role: senior_manager, hours: 16, scale: false}
|
- {task: "색인·CWV 진단", desc: "GSC·SEO Tools 활용 색인/크롤오류/Core Web Vitals 진단", role: senior_manager, hours: 16, scale: false}
|
||||||
- name: "On-page SEO"
|
- name: "On-page SEO"
|
||||||
trigger: [onpage, schema_entity]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "키워드·템플릿·메타 진단", desc: "중점 키워드·페이지 템플릿·메타·개체 활용 진단", role: manager, hours: 24, scale: true}
|
- {task: "키워드·템플릿·메타 진단", desc: "중점 키워드·페이지 템플릿·메타·개체 활용 진단", role: manager, hours: 24, scale: true}
|
||||||
- {task: "링크·콘텐츠 도구 진단", desc: "내외부 링크·공유 메타·콘텐츠 생성-관리 도구", role: manager, hours: 12, scale: true}
|
- {task: "링크·콘텐츠 도구 진단", desc: "내외부 링크·공유 메타·콘텐츠 생성-관리 도구", role: manager, hours: 12, scale: true}
|
||||||
- {task: "on-page 관리 가이드", desc: "타이틀·메타·헤더태그·링크·이미지 메타 가이드", role: manager, hours: 36, scale: true}
|
- {task: "on-page 관리 가이드", desc: "타이틀·메타·헤더태그·링크·이미지 메타 가이드", role: manager, hours: 36, scale: true}
|
||||||
- name: "SEO Growth"
|
- name: "SEO Growth"
|
||||||
trigger: [measurement, always]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "성과 지표 관리", desc: "SEO 관리지표·중점 키워드·포지셔닝 트래킹·3rd party 데이터 설정", role: manager, hours: 24, scale: false}
|
- {task: "성과 지표 관리", desc: "SEO 관리지표·중점 키워드·포지셔닝 트래킹·3rd party 데이터 설정", role: manager, hours: 24, scale: false}
|
||||||
|
|
||||||
treatment:
|
treatment:
|
||||||
service: "SEO Audit & Treatment"
|
label: "SEO Audit & Treatment"
|
||||||
modules:
|
modules:
|
||||||
- name: "Planning & Management"
|
- name: "Planning & Management"
|
||||||
trigger: [always]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "업무 관리", desc: "프로젝트 설계·과업 정의·일정/산출물 관리·리포팅", role: senior_manager, hours: 36, scale: false}
|
- {task: "업무 관리", desc: "프로젝트 설계·과업 정의·일정/산출물 관리·리포팅", role: senior_manager, hours: 36, scale: false}
|
||||||
- {task: "웹 사이트 분석", desc: "사용자 유입·채널 내 행동·전환 flow 분석", role: senior_manager, hours: 60, scale: false}
|
- {task: "웹 사이트 분석", desc: "사용자 유입·채널 내 행동·전환 flow 분석", role: senior_manager, hours: 60, scale: false}
|
||||||
- {task: "측정 계획 수립", desc: "채널 운영 목적·사용자 세그먼트·기대행동 가설 도출", role: senior_manager, hours: 20, scale: false}
|
- {task: "측정 계획 수립", desc: "채널 운영 목적·사용자 세그먼트·기대행동 가설 도출", role: senior_manager, hours: 20, scale: false}
|
||||||
- name: "Technical SEO"
|
- name: "Technical SEO"
|
||||||
trigger: [crawlability, cwv, schema_entity]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리, Site Health Check 도구 설정", role: manager, hours: 24, scale: false}
|
- {task: "Crawling & Indexing 설정", desc: "검색사이트 등록/수집 관리·Site Health Check 도구 설정", role: manager, hours: 24, scale: false}
|
||||||
- {task: "속도·UX·리다이렉트", desc: "로딩속도·페이지 UX·링크·리다이렉트·수집 제외 설정", role: technical_advisor, hours: 30, scale: false}
|
- {task: "속도·UX·리다이렉트", desc: "로딩속도·페이지 UX·링크·리다이렉트·수집 제외 설정", role: technical_advisor, hours: 30, scale: false}
|
||||||
- {task: "사이트/URL 구조·보안", desc: "구조·URL·메타데이터·사이트맵·보안 관리 진단", role: senior_manager, hours: 24, scale: false}
|
- {task: "사이트/URL 구조·보안", desc: "구조·URL·메타데이터·사이트맵·보안 관리 진단", role: senior_manager, hours: 24, scale: false}
|
||||||
- {task: "모바일·CWV·개선과제", desc: "모바일 최적화·Core Web Vitals 진단·개선 과제 도출", role: manager, hours: 20, scale: false}
|
- {task: "모바일·CWV·개선과제", desc: "모바일 최적화·Core Web Vitals 진단·개선 과제 도출", role: manager, hours: 20, scale: false}
|
||||||
- name: "On-page SEO"
|
- name: "On-page SEO"
|
||||||
trigger: [onpage, schema_entity]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "키워드·템플릿·메타 진단", desc: "중점 키워드·페이지 템플릿·메타·개체 활용 진단", role: manager, hours: 48, scale: true}
|
- {task: "키워드·템플릿·메타 진단", desc: "중점 키워드·페이지 템플릿·메타·개체 활용 진단", role: manager, hours: 48, scale: true}
|
||||||
- {task: "링크·콘텐츠 도구 진단", desc: "내외부 링크·공유 메타·콘텐츠 생성-관리 도구", role: manager, hours: 36, scale: true}
|
- {task: "링크·콘텐츠 도구 진단", desc: "내외부 링크·공유 메타·콘텐츠 생성-관리 도구", role: manager, hours: 36, scale: true}
|
||||||
- {task: "on-page 관리 가이드", desc: "타이틀·메타·헤더태그·링크·이미지 메타 가이드", role: manager, hours: 60, scale: true}
|
- {task: "on-page 관리 가이드", desc: "타이틀·메타·헤더태그·링크·이미지 메타 가이드", role: manager, hours: 60, scale: true}
|
||||||
- name: "SEO Growth"
|
- name: "SEO Growth"
|
||||||
trigger: [measurement, always]
|
|
||||||
tasks:
|
tasks:
|
||||||
- {task: "핵심 성과 지표 설정", desc: "중점과제·목표·SEO 핵심성과지표·모니터링 지표 설정", role: senior_manager, hours: 20, scale: false}
|
- {task: "핵심 성과 지표 설정", desc: "중점과제·목표·SEO 핵심성과지표·모니터링 지표 설정", role: senior_manager, hours: 20, scale: false}
|
||||||
- {task: "지표 정의·리포팅 설계", desc: "데이터 소스·지표 산식·리포팅/진단 주기 설정", role: manager, hours: 16, scale: false}
|
- {task: "지표 정의·리포팅 설계", desc: "데이터 소스·지표 산식·리포팅/진단 주기 설정", role: manager, hours: 16, scale: false}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# OurDigital / D.intelligence — UNIVERSAL rate card (shared across all service lines).
|
||||||
|
# Methods: effort (role×billing×hours), coaching (lesson_type×hours), procurement (+15%).
|
||||||
|
# Source: real quotes (06_Working Template = SEO; 견적 자료 = GA4/GTM, education). Keep PRIVATE.
|
||||||
|
|
||||||
|
company:
|
||||||
|
legal_name: "(주)디인텔리전스 (D.intelligence Lab)"
|
||||||
|
brand: "OurDigital"
|
||||||
|
ceo: "임명재"
|
||||||
|
contact: "info@ourdigital.org"
|
||||||
|
address: "경기도 성남시 수정구 창업로 43 판교글로벌비즈센터 업무동 1층 128호"
|
||||||
|
|
||||||
|
quote_prefix: OD
|
||||||
|
currency: KRW
|
||||||
|
rounding_unit: 500000 # 제안가 절사 단위(효과 기반 프로젝트 견적). 실제 견적 25,340,000→25,000,000 재현.
|
||||||
|
|
||||||
|
terms:
|
||||||
|
vat: "부가세 별도"
|
||||||
|
validity_days: 14
|
||||||
|
payment: "현금"
|
||||||
|
|
||||||
|
# ── effort method (project services: SEO Audit & Treatment 등) ──────────────
|
||||||
|
billing_rate: 0.70 # 청구율 표준 70%
|
||||||
|
billing_rate_floor: 0.60 # 협상 시 -10%p 까지(>10%p 는 CEO/CFO 승인)
|
||||||
|
basis: {hours_per_day: 8, weeks_per_month: 4}
|
||||||
|
procurement_markup: 0.15 # 외부 조달 물품/서비스 수수료
|
||||||
|
role_rates: # 시간 단가 KRW/hr (직급 기준)
|
||||||
|
ceo: 180000
|
||||||
|
evp: 150000
|
||||||
|
svp: 120000
|
||||||
|
technical_advisor: 120000
|
||||||
|
director: 100000
|
||||||
|
senior_manager: 90000 # 부장 (Senior SEO·PM·Senior Analyst·Account Director)
|
||||||
|
deputy_manager: 80000 # 차장
|
||||||
|
manager: 70000 # 과장 (SEO·Data Analyst·Engineer|Developer)
|
||||||
|
assistant_manager: 60000 # 대리
|
||||||
|
junior: 50000 # 주임
|
||||||
|
associate: 30000 # 사원
|
||||||
|
intern: 12000
|
||||||
|
tiering: # 자동 티어 선택 보정(effort 카탈로그가 tier 를 가질 때)
|
||||||
|
premium_verticals: ["luxury", "premium", "deluxe", "5_star", "5성", "특1급", "boutique_luxury"]
|
||||||
|
premium_min_tier: basic # 프리미엄 단일 프로퍼티 최소 티어
|
||||||
|
scaling: # On-page(scale:true) 시간 스케일 — 브랜드/템플릿 수 기준, 캡 ×2.0
|
||||||
|
driver: subbrands_total
|
||||||
|
bands: [[1, 1.0], [3, 1.3], [6, 1.6], [999999, 2.0]]
|
||||||
|
tools:
|
||||||
|
semrush_guru: {label: "Advanced SEO Tools — SEMrush Guru", unit: "월간 구독", price_usd: 249.95, note: "고객사 별도 구독(TBD)"}
|
||||||
|
|
||||||
|
# ── coaching method (education: 1:1 코칭, 워크숍) ────────────────────────────
|
||||||
|
# 실제 견적 재현: 비용 = Σ(lesson_type_prices[type] × hours). subject 행렬은 base×level_multiple
|
||||||
|
# (list price) 이나 실제 견적은 base 가격으로 청구되므로 pricing_mode 기본값 base.
|
||||||
|
coaching:
|
||||||
|
pricing_mode: base # base | matrix
|
||||||
|
lesson_type_prices: {화상: 80000, 대면: 100000, 실습: 150000, 워크숍: 300000, 트리트먼트: 500000}
|
||||||
|
level_multiple: {Beginners: 1.0, Intermediate: 1.5, Advanced: 2.0, Expert: 2.5, Trainers: 3.0}
|
||||||
|
subject_levels: # matrix 모드에서 subject×type 단가 = base × level_multiple[level]
|
||||||
|
"Content Marketing": Beginners
|
||||||
|
"SEO": Beginners
|
||||||
|
"Google Analytics": Intermediate
|
||||||
|
"Google Tag Manager": Advanced
|
||||||
|
"Digital Marketing Strategy": Advanced
|
||||||
|
"Digital Communication": Intermediate
|
||||||
|
"KPI Setup & Measurement Plan": Expert
|
||||||
|
"Business Model Canvas": Expert
|
||||||
|
"e-Commerce Audit": Advanced
|
||||||
|
student_discount:
|
||||||
|
apply_default: false # 실제 1:1 견적은 할인 미적용 → 기본 off (opt-in)
|
||||||
|
bands: [[5, 0.20], [10, 0.0], [20, 0.20], [30, 0.30]] # 30+ = 별도 협의
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "ourdigital-estimate-engine scope input",
|
||||||
|
"description": "Generic estimate request. Consuming skills map their context into this.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["service"],
|
||||||
|
"properties": {
|
||||||
|
"service": {"type": "string", "description": "catalog entry name, e.g. seo | education | digital_ads"},
|
||||||
|
"method": {"type": "string", "enum": ["effort", "coaching", "procurement"], "description": "optional; defaults to the catalog entry's method"},
|
||||||
|
"prospect": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"}, "domain": {"type": "string"},
|
||||||
|
"audit_date": {"type": "string"}, "account_code": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"seq": {"type": "integer", "default": 1},
|
||||||
|
|
||||||
|
"tier": {"type": "string", "description": "effort: smb|basic|treatment|auto (default auto)"},
|
||||||
|
"billing_rate": {"type": ["number", "null"], "description": "effort: override billing rate"},
|
||||||
|
"signals": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "effort auto-tiering inputs",
|
||||||
|
"properties": {
|
||||||
|
"properties_total": {"type": "integer"},
|
||||||
|
"subbrands_total": {"type": "integer"},
|
||||||
|
"vertical": {"type": "string"},
|
||||||
|
"severity": {"type": "array", "items": {"type": "string"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"course": {"type": "string", "description": "coaching: named course in catalog (else use lessons)"},
|
||||||
|
"lessons": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "coaching: explicit lesson plan",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "hours"],
|
||||||
|
"properties": {
|
||||||
|
"subject": {"type": "string"}, "title": {"type": "string"},
|
||||||
|
"type": {"type": "string", "enum": ["화상", "대면", "실습", "워크숍", "트리트먼트"]},
|
||||||
|
"hours": {"type": "number"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"students": {"type": "integer", "default": 1},
|
||||||
|
"pricing_mode": {"type": "string", "enum": ["base", "matrix"]},
|
||||||
|
"apply_student_discount": {"type": "boolean"},
|
||||||
|
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "procurement: non-labor line items",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["label", "unit_cost"],
|
||||||
|
"properties": {
|
||||||
|
"label": {"type": "string"}, "unit_cost": {"type": "number"},
|
||||||
|
"qty": {"type": "number", "default": 1}, "currency": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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