From c9bdbb57f7ffa6b4bb24bf83119fc70a0b8218f4 Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Thu, 28 May 2026 01:54:11 +0900 Subject: [PATCH] Extract ourdigital-estimate-engine; presales-seo now calls it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../95-ourdigital-presales-seo/SKILL.md | 10 +- .../references/findings_to_service.md | 7 +- .../references/rate_card.yaml | 66 ----- .../scripts/build_deck.py | 2 +- .../scripts/estimate.py | 247 ------------------ .../scripts/findings_to_scope.py | 56 ++++ .../96-ourdigital-estimate-engine/DESIGN.md | 120 +++++++++ .../96-ourdigital-estimate-engine/SKILL.md | 55 ++++ .../catalog/digital_ads.yaml | 17 ++ .../catalog/digital_branding.yaml | 16 ++ .../catalog/education.yaml | 31 +++ .../catalog/seo.yaml} | 41 +-- .../references/rate_card.yaml | 67 +++++ .../scope.schema.json | 63 +++++ .../scripts/estimate.py | 86 ++++++ .../scripts/methods/__init__.py | 1 + .../scripts/methods/coaching.py | 61 +++++ .../scripts/methods/effort.py | 84 ++++++ .../scripts/methods/procurement.py | 18 ++ .../scripts/render.py | 123 +++++++++ 20 files changed, 822 insertions(+), 349 deletions(-) delete mode 100644 custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml delete mode 100755 custom-skills/95-ourdigital-presales-seo/scripts/estimate.py create mode 100644 custom-skills/95-ourdigital-presales-seo/scripts/findings_to_scope.py create mode 100644 custom-skills/96-ourdigital-estimate-engine/DESIGN.md create mode 100644 custom-skills/96-ourdigital-estimate-engine/SKILL.md create mode 100644 custom-skills/96-ourdigital-estimate-engine/catalog/digital_ads.yaml create mode 100644 custom-skills/96-ourdigital-estimate-engine/catalog/digital_branding.yaml create mode 100644 custom-skills/96-ourdigital-estimate-engine/catalog/education.yaml rename custom-skills/{95-ourdigital-presales-seo/references/sow_templates.yaml => 96-ourdigital-estimate-engine/catalog/seo.yaml} (79%) create mode 100644 custom-skills/96-ourdigital-estimate-engine/references/rate_card.yaml create mode 100644 custom-skills/96-ourdigital-estimate-engine/scope.schema.json create mode 100644 custom-skills/96-ourdigital-estimate-engine/scripts/estimate.py create mode 100644 custom-skills/96-ourdigital-estimate-engine/scripts/methods/__init__.py create mode 100644 custom-skills/96-ourdigital-estimate-engine/scripts/methods/coaching.py create mode 100644 custom-skills/96-ourdigital-estimate-engine/scripts/methods/effort.py create mode 100644 custom-skills/96-ourdigital-estimate-engine/scripts/methods/procurement.py create mode 100644 custom-skills/96-ourdigital-estimate-engine/scripts/render.py diff --git a/custom-skills/95-ourdigital-presales-seo/SKILL.md b/custom-skills/95-ourdigital-presales-seo/SKILL.md index b77aa86..f5a4193 100644 --- a/custom-skills/95-ourdigital-presales-seo/SKILL.md +++ b/custom-skills/95-ourdigital-presales-seo/SKILL.md @@ -55,10 +55,12 @@ headless Chrome, python-pptx. Create `data/` subfolder. Initialize `findings.jso → write `03_presales-opportunity-brief.md`. ## Stage 5 — Estimate (견적) — REVIEW GATE -- `python scripts/estimate.py --findings /data/findings.json --rate-card references/rate_card.yaml --sow references/sow_templates.yaml --out-dir --seq [--baseline basic|treatment] [--billing 0.70]` -- **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`). -- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. Present the 견적; get sign-off. -- 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). +Pricing is delegated to the **`ourdigital-estimate-engine`** skill (`../96-ourdigital-estimate-engine`); this skill only maps findings → scope, then calls the engine: +- `python scripts/findings_to_scope.py --findings /data/findings.json --out /data/scope.json --seq [--tier auto|smb|basic|treatment] [--billing 0.70]` +- `ENG=../96-ourdigital-estimate-engine; python $ENG/scripts/estimate.py --rate-card $ENG/references/rate_card.yaml --catalog-dir $ENG/catalog --scope /data/scope.json --out-dir ` +- 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 - **Client PDF**: author the short brief HTML from `templates/client_brief.html` (fill the content; keep the CSS), diff --git a/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md b/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md index 598d686..78db080 100644 --- a/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md +++ b/custom-skills/95-ourdigital-presales-seo/references/findings_to_service.md @@ -44,5 +44,8 @@ markup if billed through us. VAT 별도 · 유효기간 14d · 현금 · 절사 - `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) -Edit `rate_card.yaml` + `sow_templates.yaml` together when rates, hours, tiers, or -scaling change. +**Pricing now lives in the `ourdigital-estimate-engine` skill** (`../96-ourdigital-estimate-engine`): +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. diff --git a/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml b/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml deleted file mode 100644 index 9b79e44..0000000 --- a/custom-skills/95-ourdigital-presales-seo/references/rate_card.yaml +++ /dev/null @@ -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), 사용자별 과금" diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py b/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py index ce1303b..511e781 100755 --- a/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py +++ b/custom-skills/95-ourdigital-presales-seo/scripts/build_deck.py @@ -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.columns[0].width = Inches(8.4) 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.text = htxt c.fill.solid() diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py b/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py deleted file mode 100755 index 3b795db..0000000 --- a/custom-skills/95-ourdigital-presales-seo/scripts/estimate.py +++ /dev/null @@ -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 --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() diff --git a/custom-skills/95-ourdigital-presales-seo/scripts/findings_to_scope.py b/custom-skills/95-ourdigital-presales-seo/scripts/findings_to_scope.py new file mode 100644 index 0000000..9df8bb3 --- /dev/null +++ b/custom-skills/95-ourdigital-presales-seo/scripts/findings_to_scope.py @@ -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 /scripts/estimate.py --rate-card /references/rate_card.yaml \ + --catalog-dir /catalog --scope scope.json --out-dir + +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() diff --git a/custom-skills/96-ourdigital-estimate-engine/DESIGN.md b/custom-skills/96-ourdigital-estimate-engine/DESIGN.md new file mode 100644 index 0000000..fca10ff --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/DESIGN.md @@ -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 [--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. diff --git a/custom-skills/96-ourdigital-estimate-engine/SKILL.md b/custom-skills/96-ourdigital-estimate-engine/SKILL.md new file mode 100644 index 0000000..a8d9852 --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/SKILL.md @@ -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/.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 [--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/.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. diff --git a/custom-skills/96-ourdigital-estimate-engine/catalog/digital_ads.yaml b/custom-skills/96-ourdigital-estimate-engine/catalog/digital_ads.yaml new file mode 100644 index 0000000..adb5acf --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/catalog/digital_ads.yaml @@ -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} diff --git a/custom-skills/96-ourdigital-estimate-engine/catalog/digital_branding.yaml b/custom-skills/96-ourdigital-estimate-engine/catalog/digital_branding.yaml new file mode 100644 index 0000000..be19d0b --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/catalog/digital_branding.yaml @@ -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} diff --git a/custom-skills/96-ourdigital-estimate-engine/catalog/education.yaml b/custom-skills/96-ourdigital-estimate-engine/catalog/education.yaml new file mode 100644 index 0000000..8c2910c --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/catalog/education.yaml @@ -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. diff --git a/custom-skills/95-ourdigital-presales-seo/references/sow_templates.yaml b/custom-skills/96-ourdigital-estimate-engine/catalog/seo.yaml similarity index 79% rename from custom-skills/95-ourdigital-presales-seo/references/sow_templates.yaml rename to custom-skills/96-ourdigital-estimate-engine/catalog/seo.yaml index 9df39cc..c2d77f8 100644 --- a/custom-skills/95-ourdigital-presales-seo/references/sow_templates.yaml +++ b/custom-skills/96-ourdigital-estimate-engine/catalog/seo.yaml @@ -1,90 +1,73 @@ -# SOW task templates — standard 업무 시간(hours) by module. -# Priced via rate_card.yaml: cost = role_rate × billing_rate × hours. -# Tiers (제안가 at default billing): smb < basic < treatment. -# basic -> ₩10,500,000 (real SEO Basic & Coaching quote, billing 0.70) -# treatment -> ₩25,000,000 (real SEO Audit & Treatment quote, billing 0.70) -# smb -> lean entry tier for single-property SMBs (billing 0.55) -# 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: +# SEO service catalog — method: effort. Tiers priced via rate_card (role×billing×hours). +# Reproduces real quotes: basic ₩10.5M, treatment ₩25.0M (1 property, billing 0.70). +# Only On-page tasks (scale:true) scale by sub-brands; Technical/P&M/Growth fixed. +service: seo +method: effort +tiers: smb: - service: "SEO Quick Audit (SMB)" - billing_rate: 0.55 # SMB 진입 티어 — 낮은 청구율 + label: "SEO Quick Audit (SMB)" + billing_rate: 0.55 modules: - name: "Planning & Management" - trigger: [always] tasks: - {task: "업무 관리·착수", desc: "과업 정의·일정·리포팅", role: senior_manager, hours: 8, scale: false} - {task: "웹 사이트 분석", desc: "유입·행동·전환 flow 요약 분석", role: senior_manager, hours: 8, scale: false} - name: "Technical SEO" - trigger: [crawlability, cwv, schema_entity] tasks: - {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: "구조·메타·사이트맵 점검", desc: "사이트/URL 구조·메타·사이트맵·색인", role: manager, hours: 8, scale: false} - name: "On-page SEO" - trigger: [onpage, schema_entity] tasks: - {task: "키워드·메타 진단", desc: "중점 키워드·페이지 메타·템플릿 진단", role: manager, hours: 12, scale: true} - {task: "on-page 퀵윈 가이드", desc: "타이틀·메타·헤더·링크·이미지 핵심 개선 가이드", role: manager, hours: 16, scale: true} - name: "SEO Growth" - trigger: [measurement, always] tasks: - {task: "기본 성과 지표 설정", desc: "핵심 SEO 지표·중점 키워드 트래킹 설정", role: manager, hours: 8, scale: false} basic: - service: "SEO Audit & Basic Treatment" + label: "SEO Audit & Basic Treatment" modules: - name: "Planning & Management" - trigger: [always] tasks: - {task: "업무 관리", desc: "프로젝트 설계·과업 정의·일정/산출물 관리·리포팅", role: senior_manager, hours: 20, scale: false} - {task: "웹 사이트 분석", desc: "사용자 유입·채널 내 행동·전환 flow 분석", role: senior_manager, hours: 12, scale: false} - name: "Technical SEO" - trigger: [crawlability, cwv, schema_entity] 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: "사이트/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} - name: "On-page SEO" - trigger: [onpage, schema_entity] tasks: - {task: "키워드·템플릿·메타 진단", desc: "중점 키워드·페이지 템플릿·메타·개체 활용 진단", role: manager, hours: 24, scale: true} - {task: "링크·콘텐츠 도구 진단", desc: "내외부 링크·공유 메타·콘텐츠 생성-관리 도구", role: manager, hours: 12, scale: true} - {task: "on-page 관리 가이드", desc: "타이틀·메타·헤더태그·링크·이미지 메타 가이드", role: manager, hours: 36, scale: true} - name: "SEO Growth" - trigger: [measurement, always] tasks: - {task: "성과 지표 관리", desc: "SEO 관리지표·중점 키워드·포지셔닝 트래킹·3rd party 데이터 설정", role: manager, hours: 24, scale: false} treatment: - service: "SEO Audit & Treatment" + label: "SEO Audit & Treatment" modules: - name: "Planning & Management" - trigger: [always] tasks: - {task: "업무 관리", desc: "프로젝트 설계·과업 정의·일정/산출물 관리·리포팅", role: senior_manager, hours: 36, scale: false} - {task: "웹 사이트 분석", desc: "사용자 유입·채널 내 행동·전환 flow 분석", role: senior_manager, hours: 60, scale: false} - {task: "측정 계획 수립", desc: "채널 운영 목적·사용자 세그먼트·기대행동 가설 도출", role: senior_manager, hours: 20, scale: false} - name: "Technical SEO" - trigger: [crawlability, cwv, schema_entity] 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: "사이트/URL 구조·보안", desc: "구조·URL·메타데이터·사이트맵·보안 관리 진단", role: senior_manager, hours: 24, scale: false} - {task: "모바일·CWV·개선과제", desc: "모바일 최적화·Core Web Vitals 진단·개선 과제 도출", role: manager, hours: 20, scale: false} - name: "On-page SEO" - trigger: [onpage, schema_entity] tasks: - {task: "키워드·템플릿·메타 진단", desc: "중점 키워드·페이지 템플릿·메타·개체 활용 진단", role: manager, hours: 48, scale: true} - {task: "링크·콘텐츠 도구 진단", desc: "내외부 링크·공유 메타·콘텐츠 생성-관리 도구", role: manager, hours: 36, scale: true} - {task: "on-page 관리 가이드", desc: "타이틀·메타·헤더태그·링크·이미지 메타 가이드", role: manager, hours: 60, scale: true} - name: "SEO Growth" - trigger: [measurement, always] tasks: - {task: "핵심 성과 지표 설정", desc: "중점과제·목표·SEO 핵심성과지표·모니터링 지표 설정", role: senior_manager, hours: 20, scale: false} - {task: "지표 정의·리포팅 설계", desc: "데이터 소스·지표 산식·리포팅/진단 주기 설정", role: manager, hours: 16, scale: false} diff --git a/custom-skills/96-ourdigital-estimate-engine/references/rate_card.yaml b/custom-skills/96-ourdigital-estimate-engine/references/rate_card.yaml new file mode 100644 index 0000000..2c37d21 --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/references/rate_card.yaml @@ -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+ = 별도 협의 diff --git a/custom-skills/96-ourdigital-estimate-engine/scope.schema.json b/custom-skills/96-ourdigital-estimate-engine/scope.schema.json new file mode 100644 index 0000000..6f2e82c --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/scope.schema.json @@ -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"} + } + } + } + } +} diff --git a/custom-skills/96-ourdigital-estimate-engine/scripts/estimate.py b/custom-skills/96-ourdigital-estimate-engine/scripts/estimate.py new file mode 100644 index 0000000..1364996 --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/scripts/estimate.py @@ -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 [--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() diff --git a/custom-skills/96-ourdigital-estimate-engine/scripts/methods/__init__.py b/custom-skills/96-ourdigital-estimate-engine/scripts/methods/__init__.py new file mode 100644 index 0000000..53836c6 --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/scripts/methods/__init__.py @@ -0,0 +1 @@ +# estimate-engine costing methods: effort, coaching, procurement. diff --git a/custom-skills/96-ourdigital-estimate-engine/scripts/methods/coaching.py b/custom-skills/96-ourdigital-estimate-engine/scripts/methods/coaching.py new file mode 100644 index 0000000..a24785d --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/scripts/methods/coaching.py @@ -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)), + } diff --git a/custom-skills/96-ourdigital-estimate-engine/scripts/methods/effort.py b/custom-skills/96-ourdigital-estimate-engine/scripts/methods/effort.py new file mode 100644 index 0000000..b8c4809 --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/scripts/methods/effort.py @@ -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)), + } diff --git a/custom-skills/96-ourdigital-estimate-engine/scripts/methods/procurement.py b/custom-skills/96-ourdigital-estimate-engine/scripts/methods/procurement.py new file mode 100644 index 0000000..7d984d2 --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/scripts/methods/procurement.py @@ -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)), + } diff --git a/custom-skills/96-ourdigital-estimate-engine/scripts/render.py b/custom-skills/96-ourdigital-estimate-engine/scripts/render.py new file mode 100644 index 0000000..f2b5655 --- /dev/null +++ b/custom-skills/96-ourdigital-estimate-engine/scripts/render.py @@ -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"))