Add real digital_ads + digital_branding catalogs

Built from real D.intelligence docs (not the 견적 자료 folder, which has only
GA4/GTM + education — confirmed):
- digital_branding: TNS 유학원 디지털 브랜딩 진단 컨설팅 → ₩9,000,000 (5 fixed stages)
- digital_ads: 디하이브 디지털 광고·퍼포먼스 마케팅 대행 계약 → ₩6,000,000/월 retainer
  (media-spend commission % is per-deal, kept as a parameter — not invented)

effort method now supports fixed-amount tasks (Unit Cost) and monthly retainers
(unit: monthly); render shows 고정/—//월 and retainer/commission notes.
Validated: branding ₩9.0M, ads ₩6.0M/월; no regression (SEO 25.0M, coaching 1.57M).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 02:06:59 +09:00
parent c9bdbb57f7
commit 08cb20fc67
6 changed files with 76 additions and 45 deletions

View File

@@ -24,8 +24,8 @@ All methods share: cover-sheet output, 부가세 별도, 유효기간 14d, 현
(500k floor for project quotes), quote no `OD-YYYY-NNN`. (500k floor for project quotes), quote no `OD-YYYY-NNN`.
### Non-goals ### Non-goals
- No invented rates. `digital_ads` / `digital_branding` ship as **effort stubs** (clearly - No invented rates. `digital_ads` / `digital_branding` are built from real docs (디하이브 ads
marked) until real quotes exist. 대행 계약, TNS 브랜딩 진단 견적); any future service without real data ships as a flagged stub.
- No GA4/GTM *effort-implementation* catalog (delivered as coaching → a course in education). - 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 - Does not replace consuming skills' domain logic (e.g. SEO findings→scope mapping stays in
presales-seo). presales-seo).
@@ -39,8 +39,8 @@ All methods share: cover-sheet output, 부가세 별도, 유효기간 14d, 현
catalog/ catalog/
seo.yaml # method: effort — tiers smb/basic/treatment (real) seo.yaml # method: effort — tiers smb/basic/treatment (real)
education.yaml # method: coaching — courses + per-subject levels (real) education.yaml # method: coaching — courses + per-subject levels (real)
digital_ads.yaml # method: effort — STUB digital_ads.yaml # method: effort — REAL (₩6.0M/월 retainer + per-deal media commission)
digital_branding.yaml # method: effort — STUB digital_branding.yaml # method: effort — REAL (fixed 진단 stages → ₩9.0M)
scripts/ scripts/
estimate.py # CLI dispatcher estimate.py # CLI dispatcher
methods/effort.py methods/effort.py
@@ -115,6 +115,7 @@ Each file: `service`, `method`, and method-specific body.
- presales-seo end-to-end: findings→scope→engine→견적 + deck reproduces SHR/L'Escape. - presales-seo end-to-end: findings→scope→engine→견적 + deck reproduces SHR/L'Escape.
## 9. Future ## 9. Future
Populate `digital_ads` / `digital_branding` from real quotes; add `content_marketing` as a `digital_ads` / `digital_branding` now real (effort: monthly retainer / fixed stages). Next:
optional **commission** line for ad media spend (per-deal %); add `content_marketing` as a
project-service if effort quotes emerge (currently a coaching subject); optional matrix-mode project-service if effort quotes emerge (currently a coaching subject); optional matrix-mode
quotes; more education courses. coaching quotes; more education courses.

View File

@@ -12,7 +12,9 @@ catalog and renders the 견적.
## Costing methods (catalog entry declares its `method`) ## Costing methods (catalog entry declares its `method`)
- **effort** — `role_rate × 청구율(0.70) × 표준 업무시간`, by module; auto-tier (smb/basic/treatment) - **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); 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). 제안가 = 합계 절사. Also supports **fixed-amount tasks** (`fixed:` Unit Cost — e.g. branding
진단 stages) and **monthly retainers** (`unit: monthly` — e.g. ads 대행). Reproduces real
SEO (10.5M/25.0M), branding (₩9.0M), ads (₩6.0M/월).
- **coaching** — `Σ(lesson_type_price × hours)` over a lesson plan; student discount opt-in. - **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. 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. - **procurement** — `unit_cost × qty × (1 + 0.15)` for non-labor items.
@@ -20,8 +22,10 @@ catalog and renders the 견적.
## Files ## Files
- `references/rate_card.yaml` — universal: role rates, billing, basis, terms, tiering, scaling, - `references/rate_card.yaml` — universal: role rates, billing, basis, terms, tiering, scaling,
procurement, coaching prices/levels/discounts, tools. **Private.** procurement, coaching prices/levels/discounts, tools. **Private.**
- `catalog/<service>.yaml` — per-service: `service`, `method`, body. Real: `seo` (effort), - `catalog/<service>.yaml` — per-service: `service`, `method`, body. All real:
`education` (coaching). Stubs: `digital_ads`, `digital_branding` (effort — `_stub: true`). `seo` (effort, role-hours tiers), `education` (coaching, incl. GA4/GTM course),
`digital_branding` (effort, fixed 진단 stages → ₩9.0M), `digital_ads` (effort, ₩6.0M/월
retainer; media commission % is per-deal, not in catalog).
- `scripts/estimate.py` — CLI dispatcher; `scripts/methods/{effort,coaching,procurement}.py`; - `scripts/estimate.py` — CLI dispatcher; `scripts/methods/{effort,coaching,procurement}.py`;
`scripts/render.py` (md/xlsx/json). `scope.schema.json` — input contract. `scripts/render.py` (md/xlsx/json). `scope.schema.json` — input contract.
@@ -46,8 +50,8 @@ Example consumer: `ourdigital-presales-seo` (`findings_to_scope.py` → engine
## Adding / editing services ## Adding / editing services
- New real service: add `catalog/<service>.yaml` with `method` + body from a **real quote**. - 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 - Any new stub should carry `_stub: true` (the 견적 prints a ⚠STUB banner) until replaced with
with real quote data before client use (the 견적 prints a ⚠STUB banner). real quote data. All current catalogs are real.
- Rates change in `rate_card.yaml` only (single source). Validate against a known real quote. - Rates change in `rate_card.yaml` only (single source). Validate against a known real quote.
## Conventions ## Conventions

View File

@@ -1,17 +1,18 @@
# Digital Ads catalog — method: effort. STUB — placeholder hours, NOT real rates. # Digital Ads / Performance Marketing catalog — method: effort (monthly retainer).
# TODO: populate tiers/modules/hours from a real OurDigital digital-ads quote (Drive), # Source: real signed contract "D.intelligence_디지털 광고 및 퍼포먼스 마케팅 대행 계약서_(주)디하이브"
# the same way seo.yaml was built. Until then estimates here are illustrative only. # (2023-04-03) → 퍼포먼스 마케팅 대행 = 월 ₩6,000,000 (부가세 별도), 월말 세금계산서.
# Media spend (매체비) is separate; 광고대행수수료(%) on media spend is agreed per contract
# (NOT a fixed rate — kept as a per-engagement parameter, not invented here).
service: digital_ads service: digital_ads
method: effort method: effort
_stub: true
tiers: tiers:
basic: performance_retainer:
label: "Digital Ads Setup & Management (STUB)" label: "디지털 광고·퍼포먼스 마케팅 대행 (월 정기)"
notes:
- "매체비(media spend)는 별도. 집행금액 대비 광고대행수수료(%)는 계약 시 약정 — 본 견적 미포함."
- "제작비·프로모션비는 외주 실비 + 대행수수료 별도. Ad-hoc 진단/분석 보고서는 별도 약정."
- "media 커미션이 필요하면 procurement 메서드로 별도 라인 산정(요율은 per-deal)."
modules: modules:
- name: "Planning & Management" - name: "퍼포먼스 마케팅 대행"
tasks: tasks:
- {task: "캠페인 전략·계정 구조 설계", desc: "STUB — replace with real SOW", role: senior_manager, hours: 16, scale: false} - {task: "퍼포먼스 마케팅 대행", desc: "KPI 설정·측정계획·데이터/GTM 관리·정기 리포팅·SEO 모니터링 등 (계약 별첨 SOW)", fixed: 6000000, unit: monthly}
- name: "Ads Operation"
tasks:
- {task: "캠페인 셋업·태깅·전환 추적", desc: "STUB", role: manager, hours: 24, scale: false}
- {task: "운영·최적화·리포팅", desc: "STUB (월 운영)", role: manager, hours: 24, scale: true}

View File

@@ -1,16 +1,20 @@
# Digital Branding catalog — method: effort. STUB — placeholder hours, NOT real rates. # Digital Branding catalog — method: effort (fixed-amount stages).
# TODO: populate from a real OurDigital digital-branding quote (Drive). Illustrative only. # Source: real quote "D.intelligence_TNS 유학원_디지털 브랜딩 진단 컨설팅_20250625" → ₩9,000,000
# (5 fixed stages, 6~8주 진단 컨설팅). Stages are lump-sum Unit Cost (no labor-hour basis),
# per the SOW rule for non-hour-measurable tasks. Follow-on execution = separate quote.
service: digital_branding service: digital_branding
method: effort method: effort
_stub: true
tiers: tiers:
basic: diagnostic:
label: "Digital Branding (STUB)" label: "디지털 브랜딩 진단 컨설팅"
notes:
- "6~8주 일정 (착수 후 업무 수행계획서 제시)."
- "후속 실행(브랜드 아이덴티티·웹사이트·콘텐츠 마케팅)은 진단 결과에 따라 별도 견적."
modules: modules:
- name: "Planning & Management" - name: "브랜딩 진단 컨설팅"
tasks: tasks:
- {task: "브랜드 전략·포지셔닝", desc: "STUB — replace with real SOW", role: director, hours: 16, scale: false} - {task: "1단계 킥오프·과업 정의", desc: "인터뷰·자료 수집·목표 정의", fixed: 2000000, unit: one_time}
- name: "Branding Execution" - {task: "2단계 디지털 자산 진단", desc: "채널/계정/콘텐츠 구조 분석", fixed: 2000000, unit: one_time}
tasks: - {task: "3단계 브랜드 가시성 진단", desc: "검색 키워드·브랜드 명칭 중심", fixed: 2000000, unit: one_time}
- {task: "브랜드 아이덴티티·가이드", desc: "STUB", role: senior_manager, hours: 24, scale: false} - {task: "4단계 콘텐츠/채널 가시성 조사", desc: "블로그/카페/소셜 노출 현황 정리", fixed: 1500000, unit: one_time}
- {task: "디지털 자산·채널 적용", desc: "STUB", role: manager, hours: 24, scale: true} - {task: "5단계 KPI 구조·전략 로드맵", desc: "성과 프레임 기초 설계·로드맵 보고서", fixed: 1500000, unit: one_time}

View File

@@ -57,16 +57,26 @@ def build(scope, rate, catalog):
mult, driver, dcount = _scope_mult(rate, signals) mult, driver, dcount = _scope_mult(rate, signals)
roles = rate["role_rates"] roles = rate["role_rates"]
modules, grand = [], 0.0 modules, grand, recurring = [], 0.0, False
for mod in t["modules"]: for mod in t["modules"]:
tasks, sub = [], 0.0 tasks, sub = [], 0.0
for task in mod["tasks"]: for task in mod["tasks"]:
applied = mult if (task.get("scale") and mult != 1.0) else 1.0 applied = mult if (task.get("scale") and mult != 1.0) else 1.0
unit = task.get("unit", "one_time")
if unit == "monthly":
recurring = True
if "fixed" in task: # quoted lump sum / Unit Cost (no labor-hour basis)
amt = task["fixed"] * applied
tasks.append({"task": task["task"], "desc": task.get("desc", ""), "role": "fixed",
"role_rate": None, "hours": None, "amount": amt,
"scaled": applied != 1.0, "unit": unit})
else:
hours = round(task["hours"] * applied, 1) hours = round(task["hours"] * applied, 1)
rr = roles[task["role"]] rr = roles[task["role"]]
amt = rr * billing * hours amt = rr * billing * hours
tasks.append({"task": task["task"], "desc": task.get("desc", ""), "role": task["role"], tasks.append({"task": task["task"], "desc": task.get("desc", ""), "role": task["role"],
"role_rate": rr, "hours": hours, "amount": amt, "scaled": applied != 1.0}) "role_rate": rr, "hours": hours, "amount": amt,
"scaled": applied != 1.0, "unit": unit})
sub += amt sub += amt
modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks}) modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks})
grand += sub grand += sub
@@ -75,7 +85,8 @@ def build(scope, rate, catalog):
proposal = int(math.floor(grand / rounding) * rounding) proposal = int(math.floor(grand / rounding) * rounding)
return { return {
"kind": "effort", "service": catalog["service"], "label": t.get("label", catalog["service"]), "kind": "effort", "service": catalog["service"], "label": t.get("label", catalog["service"]),
"tier": tier, "billing_rate": billing, "tier": tier, "billing_rate": billing, "recurring": recurring,
"notes": t.get("notes", []),
"scope": {"driver": driver, "driver_count": dcount, "scope": {"driver": driver, "driver_count": dcount,
"properties_total": signals.get("properties_total", 0), "properties_total": signals.get("properties_total", 0),
"subbrands_total": signals.get("subbrands_total", 0), "hours_multiplier": mult}, "subbrands_total": signals.get("subbrands_total", 0), "hours_multiplier": mult},

View File

@@ -42,10 +42,16 @@ def _md(q, path):
for i, t in enumerate(m["tasks"]): for i, t in enumerate(m["tasks"]):
grp = m["name"] if i == 0 else "" grp = m["name"] if i == 0 else ""
mark = " *" if t["scaled"] 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'])} |") role_lbl = "고정" if t["role"] == "fixed" else ROLE_KO.get(t["role"], t["role"])
hrs = f"{t['hours']:g}" if t.get("hours") is not None else ""
umark = " /월" if t.get("unit") == "monthly" else ""
L.append(f"| {grp} | {t['task']}{mark} | {role_lbl} | {hrs} | {won(t['amount'])}{umark} |")
L.append(f"| | **{m['name']} 소계** | | | **{won(m['subtotal'])}** |") L.append(f"| | **{m['name']} 소계** | | | **{won(m['subtotal'])}** |")
plabel = "제안가(월/retainer)" if q.get("recurring") else "제안가(절사 적용)"
L += ["", f"- 합계: **{won(q['subtotal_sum'])}** · 청구율 {int(q['billing_rate']*100)}% · 일8h/월4주", L += ["", f"- 합계: **{won(q['subtotal_sum'])}** · 청구율 {int(q['billing_rate']*100)}% · 일8h/월4주",
f"- **제안가(절사 적용): {won(q['proposal'])}** ({q['terms']['vat']})"] f"- **{plabel}: {won(q['proposal'])}** ({q['terms']['vat']})"]
if q.get("recurring"):
L.append("- ※ '/월' 항목은 월 정기(retainer) 비용 — 계약 기간에 따라 합산.")
elif q["kind"] == "coaching": elif q["kind"] == "coaching":
L += ["## 견적 내역", "", "| 구분 | 세부 | 방식 | 시간 | 단가 | 합계 |", "|---|---|:--:|--:|--:|--:|"] L += ["## 견적 내역", "", "| 구분 | 세부 | 방식 | 시간 | 단가 | 합계 |", "|---|---|:--:|--:|--:|--:|"]
for it in q["lessons"]: for it in q["lessons"]:
@@ -62,6 +68,8 @@ def _md(q, path):
L += ["", "---", f"> {q['disclaimer']}"] L += ["", "---", f"> {q['disclaimer']}"]
if q["kind"] == "effort" and any(t["scaled"] for m in q["modules"] for t in m["tasks"]): if q["kind"] == "effort" and any(t["scaled"] for m in q["modules"] for t in m["tasks"]):
L.append("> \\* 포트폴리오 규모에 따라 업무시간이 스케일된 항목.") L.append("> \\* 포트폴리오 규모에 따라 업무시간이 스케일된 항목.")
for n in q.get("notes", []):
L.append(f"> {n}")
with open(path, "w", encoding="utf-8") as fh: with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(L) + "\n") fh.write("\n".join(L) + "\n")
@@ -85,10 +93,12 @@ def _xlsx(q, path):
hdr(["구분", "세부 업무", "담당", "시간(h)", "합계(원)"]) hdr(["구분", "세부 업무", "담당", "시간(h)", "합계(원)"])
for m in q["modules"]: for m in q["modules"]:
for i, t in enumerate(m["tasks"]): 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"]))]) role_lbl = "고정" if t["role"] == "fixed" else ROLE_KO.get(t["role"], t["role"])
hrs = t["hours"] if t.get("hours") is not None else ""
ws.append([m["name"] if i == 0 else "", t["task"], role_lbl, hrs, int(round(t["amount"]))])
ws.append(["", f"{m['name']} 소계", "", "", int(round(m["subtotal"]))]) ws.append(["", f"{m['name']} 소계", "", "", int(round(m["subtotal"]))])
ws.append([]) ws.append([])
ws.append(["", "제안가(절사 적용)", "", "", int(q["proposal"])]) ws.append(["", "제안가(월/retainer)" if q.get("recurring") else "제안가(절사 적용)", "", "", int(q["proposal"])])
widths = [22, 40, 8, 8, 16] widths = [22, 40, 8, 8, 16]
elif q["kind"] == "coaching": elif q["kind"] == "coaching":
hdr(["구분", "세부", "방식", "시간", "단가", "합계(원)"]) hdr(["구분", "세부", "방식", "시간", "단가", "합계(원)"])