Rebuild estimate engine to effort-based SOW model
Replace flat per-service ranges with OurDigital's real quoting model (role_rate × billing_rate 0.70 × standard hours), sourced from the 06_Working Template quotes: - rate_card.yaml: role rate card, billing/basis/terms, tools, scaling bands - sow_templates.yaml: basic + treatment task-hour templates - estimate.py: assemble SOW from findings, scale Technical/On-page hours by properties_total, 제안가 = 합계 floored to 500k - build_deck.py: estimate slide shows module 소계 + 제안가 (point) - findings_to_service.md / SKILL.md / DESIGN.md: synced to new model Validated: reproduces real Basic ₩10.5M and Treatment ₩25.0M exactly; SHR (25 properties) scales to ₩71.5M, L'Escape (1) = ₩25.0M. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,12 @@ Each stage appends to the shared **`findings.json`** data contract (§5), the in
|
||||
|
||||
## 5. Estimate logic
|
||||
|
||||
> **Superseded (2026-05-28):** the flat per-service ranges below were replaced by an
|
||||
> **effort-based** engine (role-hours × billing_rate 0.70) loaded from OurDigital's real
|
||||
> quotation templates. See `references/rate_card.yaml`, `references/sow_templates.yaml`,
|
||||
> and `references/findings_to_service.md`. Validated to reproduce the real Basic (₩10.5M)
|
||||
> and Treatment (₩25.0M) quotes exactly. The notes below are kept as historical context.
|
||||
|
||||
### 5.1 `rate_card.yaml` (from `ourdigital-backoffice`)
|
||||
```yaml
|
||||
quote_prefix: OD # OD-YYYY-NNN
|
||||
|
||||
@@ -55,9 +55,10 @@ 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 <out>/data/findings.json --rate-card references/rate_card.yaml --out-dir <out> --seq <N>`
|
||||
- Produces `05_estimate_ko.md`, `05_estimate.xlsx`, `data/estimate.json`. Present the ranged 견적; get sign-off.
|
||||
- Rules in `references/findings_to_service.md`; rates in `references/rate_card.yaml` (edit both together).
|
||||
- `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]`
|
||||
- **Effort-based** (OurDigital real model): cost = role_rate × 청구율 70% × 표준 업무시간, by module; 제안가 = 합계 절사. findings auto-select baseline (basic/treatment) and scale Technical/On-page hours by `properties_total`.
|
||||
- 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 quotes.
|
||||
|
||||
## 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),
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
# Findings → service rubric
|
||||
# Findings → estimate mapping (effort-based)
|
||||
|
||||
How `estimate.py` maps detected findings (from `findings.json`) to `rate_card.yaml`
|
||||
service lines. The script reads the structured signals below; this file is the
|
||||
human-readable source of truth for the rules.
|
||||
`estimate.py` builds the 견적 from `sow_templates.yaml` priced via `rate_card.yaml`:
|
||||
**cost = role_rate × billing_rate (0.70) × standard_hours**, grouped by module;
|
||||
제안가 = 합계 floored to `rounding_unit`. This mirrors OurDigital/D.intelligence's
|
||||
real SOW-based quoting.
|
||||
|
||||
| Trigger (signal in findings.json) | Service line(s) | Scope driver |
|
||||
|---|---|---|
|
||||
| `discovery.sitemap_status != 200` OR `discovery.robots_sitemap_declared == false` OR `discovery.discoverable_urls` low vs `estimated_pages` | `technical_audit` + `technical_remediation` | site size / # templates |
|
||||
| `technical.cwv.perf < 0.5` OR `cls > 0.1` OR `lcp_ms > 2500` OR `ttfb_ms > 600` | `technical_audit` (if not already) + `technical_remediation` | # templates |
|
||||
| `technical.schema.org == "bare"/"none"` OR `entity.panel != "hotel"` OR `entity.name_split` OR `entity.legacy_contamination` OR `entity.subbrands_with_entity == 0` | `schema_build` (one-time) + `onpage_entity` (retainer) | # sub-brands + # properties |
|
||||
| `entity.properties_with_entity == 0` OR `url_hygiene` contains GBP/local mismatch | `local_seo` | # properties |
|
||||
| `findings[].class` includes measurement gap / no GSC-GA4 | `ga4_impl` and/or `dashboard` (+ `gtm_setup` if tag gaps) | — |
|
||||
| `technical.meta_dupe` OR `technical.title_i18n_mismatch` OR `technical.hreflang == "incomplete"` | `onpage_entity` | # templates |
|
||||
## Baseline selection (basic vs treatment)
|
||||
- `treatment` if any finding `severity == critical` **OR** `entity.properties_total > 3`
|
||||
- else `basic`
|
||||
- override with `--baseline`.
|
||||
|
||||
**Severity → priority** (for the brief/deck ordering, not pricing):
|
||||
- `critical`: crawl/index blocking, CWV failing, entity mistyped
|
||||
- `high`: entity/sub-brand gaps, duplicate URLs, meta dupes
|
||||
- `medium`: hreflang, H1, hygiene
|
||||
## Module inclusion
|
||||
Each baseline carries the standard module set (P&M · Technical SEO · On-page SEO ·
|
||||
SEO Growth), matching real quotes. Findings justify modules via the `trigger` field
|
||||
in `sow_templates.yaml`:
|
||||
|
||||
**Quantity rules**
|
||||
- `monthly` line items use `rate_card.defaults.retainer_months` (default 6).
|
||||
- `local_seo` scope note scales with property count (`entity` / discovery counts).
|
||||
- One-time items counted once even if triggered by multiple findings.
|
||||
| Module | trigger finding classes |
|
||||
|---|---|
|
||||
| Planning & Management | always |
|
||||
| Technical SEO | crawlability, cwv, schema_entity |
|
||||
| On-page SEO | onpage, schema_entity |
|
||||
| SEO Growth | measurement, always |
|
||||
|
||||
Edit this file and `rate_card.yaml` together when rates or rules change.
|
||||
## Hours scaling (portfolio)
|
||||
Tasks marked `scale: true` (Technical SEO + On-page SEO) have their **hours**
|
||||
multiplied sub-linearly by `entity.properties_total` per `rate_card.scaling.bands`.
|
||||
P&M and SEO Growth stay fixed (management/KPI overhead is ~flat). A single-property
|
||||
prospect → ×1.0, which reproduces the real single-site quotes exactly.
|
||||
|
||||
## Tools & terms
|
||||
`tools` (e.g. SEMrush Guru) are listed separately — client-subscribed, with +15%
|
||||
procurement markup if billed through us. VAT 별도 · 유효기간 14d · 현금 · 절사 from
|
||||
`rate_card.terms`.
|
||||
|
||||
## Validated reproduction (2026-05-28)
|
||||
- `treatment` ×1.0 → 합계 25,340,000 → 제안가 **25,000,000** (real Treatment quote)
|
||||
- `basic` ×1.0 → 합계 10,612,000 → 제안가 **10,500,000** (real Basic quote)
|
||||
|
||||
Edit `rate_card.yaml` and `sow_templates.yaml` together when rates or standard hours change.
|
||||
|
||||
@@ -1,63 +1,57 @@
|
||||
# OurDigital service rate card — single source for estimate.py
|
||||
# Mirrors the ourdigital-backoffice quote ranges. Values are KRW, treated as
|
||||
# pre-sales estimate RANGES (finalize after a precise diagnostic with access).
|
||||
quote_prefix: OD # quote number format: OD-YYYY-NNN
|
||||
# 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
|
||||
|
||||
services:
|
||||
technical_audit:
|
||||
label_ko: "Technical Audit / 기술 SEO 진단"
|
||||
unit: one_time # one_time | monthly | project
|
||||
min: 3000000
|
||||
max: 5000000
|
||||
technical_remediation:
|
||||
label_ko: "기술 개선 실행 (sitemap/CWV/SSR)"
|
||||
unit: project
|
||||
min: 3000000
|
||||
max: 8000000
|
||||
onpage_entity:
|
||||
label_ko: "On-Page / Entity Optimization (월 운영)"
|
||||
unit: monthly
|
||||
min: 1500000
|
||||
max: 3000000
|
||||
schema_build:
|
||||
label_ko: "구조화 데이터(Schema) 구축 (1회)"
|
||||
unit: one_time
|
||||
min: 2000000
|
||||
max: 4000000
|
||||
local_seo:
|
||||
label_ko: "Local SEO (프로퍼티 로컬 최적화)"
|
||||
unit: monthly
|
||||
min: 1000000
|
||||
max: 2000000
|
||||
gtm_setup:
|
||||
label_ko: "GTM Setup / 태그 관리 구축"
|
||||
unit: project
|
||||
min: 2000000
|
||||
max: 4000000
|
||||
ga4_impl:
|
||||
label_ko: "GA4 Implementation / 분석 환경 구축"
|
||||
unit: project
|
||||
min: 1500000
|
||||
max: 3000000
|
||||
dashboard:
|
||||
label_ko: "Dashboard Development / 대시보드 개발"
|
||||
unit: project
|
||||
min: 3000000
|
||||
max: 6000000
|
||||
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 으로 조정.
|
||||
|
||||
defaults:
|
||||
retainer_months: 6 # default contract length for monthly line items
|
||||
disclaimer_ko: "본 견적은 공개 데이터 기반 사전 추정 범위이며, Search Console/Analytics 권한 확보 후 정밀 진단을 통해 확정됩니다."
|
||||
terms:
|
||||
vat: "부가세 별도"
|
||||
validity_days: 14 # 견적 유효기간(약 2주)
|
||||
payment: "현금"
|
||||
|
||||
# Scope scaling — monthly line items scale (sub-linearly) by portfolio size.
|
||||
# driver: a count under findings.entity (properties_total | subbrands_total).
|
||||
# bands: ordered [max_count, multiplier]; first band whose max_count >= count wins.
|
||||
# A 25-property chain costs more to run than a single hotel, but not 25x.
|
||||
# 시간 단가 (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 # 인턴
|
||||
|
||||
# 포트폴리오 규모에 따른 '시간' 스케일(서브선형). scale:true 인 task 에만 적용.
|
||||
# driver: findings.entity 의 카운트. bands: [최대값, 배수]; count <= 최대값 인 첫 밴드.
|
||||
scaling:
|
||||
local_seo:
|
||||
driver: properties_total
|
||||
bands: [[1, 1.0], [5, 1.6], [15, 2.8], [30, 4.5], [999999, 6.5]]
|
||||
onpage_entity:
|
||||
driver: subbrands_total
|
||||
bands: [[1, 1.0], [3, 1.6], [6, 2.2], [999999, 3.2]]
|
||||
|
||||
# 별도 조달 항목(인력비와 분리). 청구 시 procurement_markup 적용 가능.
|
||||
tools:
|
||||
semrush_guru:
|
||||
label: "Advanced SEO Tools — SEMrush Guru"
|
||||
unit: "월간 구독"
|
||||
price_usd: 249.95
|
||||
note: "고객사 별도 구독(TBD), 사용자별 과금"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# SOW task templates — standard 업무 시간(hours) by module.
|
||||
# Seeded from the two real OurDigital quotes so estimate.py reproduces them at
|
||||
# billing_rate 0.70:
|
||||
# basic -> 제안가 ₩10,500,000 (SEO Basic & Coaching, 3개월 프로젝트)
|
||||
# treatment -> 제안가 ₩25,000,000 (SEO Audit & Treatment, 월 정기)
|
||||
# task.role references rate_card.role_rates. scale:true → hours scaled by portfolio.
|
||||
# trigger: finding classes that justify the module (for annotation + selection).
|
||||
|
||||
baselines:
|
||||
|
||||
basic:
|
||||
service: "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: true}
|
||||
- {task: "속도·UX·수집 설정", desc: "로딩속도·페이지 UX·링크·수집 제외 설정", role: manager, hours: 16, scale: true}
|
||||
- {task: "사이트/URL 구조·메타", desc: "구조·URL·메타데이터·사이트맵·리다이렉션", role: senior_manager, hours: 12, scale: true}
|
||||
- {task: "색인·CWV 진단", desc: "GSC·SEO Tools 활용 색인/크롤오류/Core Web Vitals 진단", role: senior_manager, hours: 16, scale: true}
|
||||
- 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"
|
||||
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: true}
|
||||
- {task: "속도·UX·리다이렉트", desc: "로딩속도·페이지 UX·링크·리다이렉트·수집 제외 설정", role: technical_advisor, hours: 30, scale: true}
|
||||
- {task: "사이트/URL 구조·보안", desc: "구조·URL·메타데이터·사이트맵·보안 관리 진단", role: senior_manager, hours: 24, scale: true}
|
||||
- {task: "모바일·CWV·개선과제", desc: "모바일 최적화·Core Web Vitals 진단·개선 과제 도출", role: manager, hours: 20, scale: true}
|
||||
- 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}
|
||||
- {task: "성과 대시보드 지표", desc: "관리 지표·중점 키워드·포지셔닝 트래킹·3rd party 데이터", role: manager, hours: 20, scale: false}
|
||||
- {task: "GSC 대시보드·파이프라인", desc: "GSC 대시보드 구성·연관 데이터 파이프라인 구축", role: manager, hours: 36, scale: false}
|
||||
@@ -250,17 +250,16 @@ def main():
|
||||
[(body, 12, RGBColor(0x33, 0x3A, 0x45), False)],
|
||||
])
|
||||
|
||||
# 8) Estimate
|
||||
# 8) Estimate (effort-based: module subtotals + 제안가)
|
||||
s = blank(prs)
|
||||
header(s, "ESTIMATE", "예상 견적 (사전 추정 범위)")
|
||||
if EST:
|
||||
items = EST.get("line_items", [])
|
||||
rows = min(len(items) + 1, 9)
|
||||
tbl = s.shapes.add_table(rows, 3, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.45 * rows)).table
|
||||
tbl.columns[0].width = Inches(6.0)
|
||||
tbl.columns[1].width = Inches(2.0)
|
||||
tbl.columns[2].width = Inches(3.6)
|
||||
for j, htxt in enumerate(["항목", "단위", "금액(범위)"]):
|
||||
header(s, "ESTIMATE", "예상 견적 (사전 추정)")
|
||||
mods = (EST or {}).get("modules") if EST else None
|
||||
if mods:
|
||||
rows = len(mods) + 2 # header + modules + proposal
|
||||
tbl = s.shapes.add_table(rows, 2, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.5 * rows)).table
|
||||
tbl.columns[0].width = Inches(8.4)
|
||||
tbl.columns[1].width = Inches(3.2)
|
||||
for j, htxt in enumerate([f"{EST.get('service', 'SEO')} — 구분", "소계"]):
|
||||
c = tbl.cell(0, j)
|
||||
c.text = htxt
|
||||
c.fill.solid()
|
||||
@@ -268,20 +267,29 @@ def main():
|
||||
for para in c.text_frame.paragraphs:
|
||||
for r in para.runs:
|
||||
_style(r, 12, WHITE, True)
|
||||
unit_ko = {"one_time": "1회", "project": "프로젝트", "monthly": "월"}
|
||||
for i, it in enumerate(items[:rows - 1], 1):
|
||||
amt = f"{it['amount_min']:,}~{it['amount_max']:,}원"
|
||||
for j, v in enumerate([it["label"], unit_ko.get(it["unit"], it["unit"]), amt]):
|
||||
for i, m in enumerate(mods, 1):
|
||||
for j, v in enumerate([m["name"], f"{int(round(m['subtotal'])):,}원"]):
|
||||
c = tbl.cell(i, j)
|
||||
c.text = v
|
||||
for para in c.text_frame.paragraphs:
|
||||
for r in para.runs:
|
||||
_style(r, 11, NAVY, False)
|
||||
tot = EST.get("totals", {})
|
||||
textbox(s, 0.85, 2.0 + 0.45 * rows + 0.2, 11.6, 1.2, [
|
||||
[("총계(범위): ", 14, NAVY, True),
|
||||
(f"{tot.get('grand_min', 0):,} ~ {tot.get('grand_max', 0):,}원", 14, RED, True)],
|
||||
[(EST.get("disclaimer", ""), 10, GREY, False)],
|
||||
pr = len(mods) + 1
|
||||
for j, v in enumerate(["제안가 (절사 적용 · 부가세 별도)", f"{EST.get('proposal', 0):,}원"]):
|
||||
c = tbl.cell(pr, j)
|
||||
c.text = v
|
||||
c.fill.solid()
|
||||
c.fill.fore_color.rgb = LIGHT
|
||||
for para in c.text_frame.paragraphs:
|
||||
for r in para.runs:
|
||||
_style(r, 13 if j else 12, RED if j else NAVY, True)
|
||||
sc = EST.get("scope", {})
|
||||
note = f"청구율 {int(EST.get('billing_rate', 0.7) * 100)}% · 일8h/월4주 · SOW 기반"
|
||||
if sc.get("hours_multiplier", 1.0) != 1.0:
|
||||
note += f" · 프로퍼티 {sc.get('properties_total')}개 ×{sc['hours_multiplier']:g}"
|
||||
textbox(s, 0.85, 2.2 + 0.5 * rows, 11.6, 1.3, [
|
||||
[(note, 10, GREY, False)],
|
||||
[(EST.get("disclaimer", ""), 9, GREY, False)],
|
||||
])
|
||||
else:
|
||||
textbox(s, 0.85, 2.2, 11.6, 1.0, [[("견적 데이터(estimate.json) 미연결 — estimate.py 실행 후 재생성", 13, GREY, False)]])
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a ranged 견적 (estimate) from findings.json using the OurDigital rate card.
|
||||
"""Effort-based 견적 generator for the ourdigital-presales-seo skill (Stage 5).
|
||||
|
||||
Part of the ourdigital-presales-seo skill (Stage 5). Maps detected findings to
|
||||
rate-card service lines (see references/findings_to_service.md) and emits:
|
||||
- 05_estimate_ko.md (Korean line-item quote)
|
||||
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 (spreadsheet quote)
|
||||
- 05_estimate.xlsx
|
||||
|
||||
Usage:
|
||||
python estimate.py --findings findings.json --rate-card ../references/rate_card.yaml \
|
||||
--out-dir ./audits/2026-05-27-presales --seq 1
|
||||
python estimate.py --findings data/findings.json \
|
||||
--rate-card ../references/rate_card.yaml --sow ../references/sow_templates.yaml \
|
||||
--out-dir <engagement> --seq 1 [--baseline basic|treatment] [--billing 0.70]
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
import yaml
|
||||
@@ -22,210 +29,189 @@ from openpyxl.styles import Alignment, Font, PatternFill
|
||||
|
||||
|
||||
def won(n):
|
||||
return f"{n:,}원"
|
||||
return f"{int(round(n)):,}원"
|
||||
|
||||
|
||||
def select_services(f):
|
||||
"""Return {service_key: [reasons]} based on findings signals."""
|
||||
chosen = {}
|
||||
d = f.get("discovery", {})
|
||||
t = f.get("technical", {})
|
||||
e = f.get("entity", {})
|
||||
m = f.get("measurement", {})
|
||||
|
||||
def need(key, reason):
|
||||
chosen.setdefault(key, [])
|
||||
if reason not in chosen[key]:
|
||||
chosen[key].append(reason)
|
||||
|
||||
# Crawlability / indexation
|
||||
if d.get("sitemap_status", 200) != 200 or d.get("robots_sitemap_declared", True) is False:
|
||||
need("technical_audit", "sitemap/robots 색인 이슈")
|
||||
need("technical_remediation", "sitemap 복구·크롤성 개선")
|
||||
# Core Web Vitals
|
||||
cwv = t.get("cwv", {})
|
||||
if (cwv.get("perf", 1) < 0.5 or cwv.get("cls", 0) > 0.1
|
||||
or cwv.get("lcp_ms", 0) > 2500 or cwv.get("ttfb_ms", 0) > 600):
|
||||
need("technical_audit", "Core Web Vitals 취약")
|
||||
need("technical_remediation", "CWV(CLS/LCP/TTFB) 개선")
|
||||
# Schema / entity
|
||||
if (t.get("schema", {}).get("org") in ("bare", "none") or e.get("panel") != "hotel"
|
||||
or e.get("name_split") or e.get("legacy_contamination")
|
||||
or (e.get("subbrands_with_entity", 0) == 0 and e.get("subbrands_total", 0) > 0)):
|
||||
need("schema_build", "Organization/Hotel schema·브랜드 표기 정합")
|
||||
need("onpage_entity", "엔티티·서브브랜드 최적화")
|
||||
# Local
|
||||
hygiene = " ".join(d.get("url_hygiene", [])).lower()
|
||||
if ((e.get("properties_with_entity", 0) == 0 and e.get("properties_total", 0) > 0)
|
||||
or "local" in hygiene or "gbp" in hygiene or "dup_path" in hygiene):
|
||||
need("local_seo", "프로퍼티 로컬·GBP 정합")
|
||||
# Measurement
|
||||
if m.get("gsc_access") is False or m.get("ga4_access") is False:
|
||||
need("ga4_impl", "측정 환경(GA4) 구축")
|
||||
need("dashboard", "리포팅 대시보드 구축")
|
||||
if m.get("tag_gaps"):
|
||||
need("gtm_setup", "태그 관리(GTM) 구축")
|
||||
# On-page hygiene
|
||||
if t.get("meta_dupe") or t.get("title_i18n_mismatch") or t.get("hreflang") == "incomplete":
|
||||
need("onpage_entity", "Meta/Title/hreflang 정리")
|
||||
return chosen
|
||||
def scope_multiplier(rate, count):
|
||||
sc = rate.get("scaling", {})
|
||||
bands = sc.get("bands", [[1, 1.0]])
|
||||
c = max(int(count or 0), 1)
|
||||
for mx, m in bands:
|
||||
if c <= mx:
|
||||
return float(m)
|
||||
return float(bands[-1][1])
|
||||
|
||||
|
||||
DRIVER_LABEL = {"properties_total": "프로퍼티", "subbrands_total": "서브브랜드"}
|
||||
def pick_baseline(f, override):
|
||||
if override:
|
||||
return override
|
||||
severities = {x.get("severity") for x in f.get("findings", [])}
|
||||
props = f.get("entity", {}).get("properties_total", 0) or 0
|
||||
return "treatment" if ("critical" in severities or props > 3) else "basic"
|
||||
|
||||
|
||||
def scope_multiplier(rate, key, f):
|
||||
"""Sub-linear scope multiplier for a service, driven by portfolio size.
|
||||
|
||||
Returns (multiplier, driver, count). count is floored at 1 (unknown→base).
|
||||
"""
|
||||
rule = rate.get("scaling", {}).get(key)
|
||||
if not rule:
|
||||
return 1.0, None, None
|
||||
driver = rule["driver"]
|
||||
count = max(int(f.get("entity", {}).get(driver, 0) or 0), 1)
|
||||
for max_count, mult in rule["bands"]:
|
||||
if count <= max_count:
|
||||
return float(mult), driver, count
|
||||
return 1.0, driver, count
|
||||
|
||||
|
||||
def build_line_items(chosen, rate, f):
|
||||
months = rate["defaults"]["retainer_months"]
|
||||
order = {"one_time": 0, "project": 1, "monthly": 2}
|
||||
items = []
|
||||
for key, reasons in chosen.items():
|
||||
svc = rate["services"][key]
|
||||
unit = svc["unit"]
|
||||
qty = months if unit == "monthly" else 1
|
||||
mult, driver, count = scope_multiplier(rate, key, f)
|
||||
umin = int(round(svc["min"] * mult))
|
||||
umax = int(round(svc["max"] * mult))
|
||||
reason = "; ".join(reasons)
|
||||
scope_note = None
|
||||
if mult != 1.0:
|
||||
scope_note = f"{DRIVER_LABEL.get(driver, driver)} {count}개 기준 ×{mult:g}"
|
||||
reason = f"{reason} [{scope_note}]"
|
||||
items.append({
|
||||
"key": key, "label": svc["label_ko"], "unit": unit, "qty": qty,
|
||||
"unit_min": umin, "unit_max": umax,
|
||||
"amount_min": umin * qty, "amount_max": umax * qty,
|
||||
"reason": reason,
|
||||
"scope_multiplier": mult, "scope_driver": driver,
|
||||
"scope_count": count, "scope_note": scope_note,
|
||||
def assemble(f, rate, sow, baseline, billing):
|
||||
roles = rate["role_rates"]
|
||||
props = f.get("entity", {}).get("properties_total", 0)
|
||||
mult = scope_multiplier(rate, props)
|
||||
tpl = sow["baselines"][baseline]
|
||||
modules = []
|
||||
grand = 0.0
|
||||
for mod in tpl["modules"]:
|
||||
tasks, sub = [], 0.0
|
||||
for t in mod["tasks"]:
|
||||
applied = mult if (t.get("scale") and mult != 1.0) else 1.0
|
||||
hours = round(t["hours"] * applied, 1)
|
||||
rate_hr = roles[t["role"]]
|
||||
amount = rate_hr * billing * hours
|
||||
tasks.append({
|
||||
"task": t["task"], "desc": t.get("desc", ""), "role": t["role"],
|
||||
"role_rate": rate_hr, "hours": hours, "amount": amount,
|
||||
"scaled": applied != 1.0,
|
||||
})
|
||||
items.sort(key=lambda x: order.get(x["unit"], 9))
|
||||
return items, months
|
||||
sub += amount
|
||||
modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks})
|
||||
grand += sub
|
||||
return modules, grand, mult, props, tpl["service"]
|
||||
|
||||
|
||||
def totals(items):
|
||||
one = [i for i in items if i["unit"] != "monthly"]
|
||||
mon = [i for i in items if i["unit"] == "monthly"]
|
||||
return {
|
||||
"one_time_min": sum(i["amount_min"] for i in one),
|
||||
"one_time_max": sum(i["amount_max"] for i in one),
|
||||
"monthly_min": sum(i["amount_min"] for i in mon),
|
||||
"monthly_max": sum(i["amount_max"] for i in mon),
|
||||
"grand_min": sum(i["amount_min"] for i in items),
|
||||
"grand_max": sum(i["amount_max"] for i in items),
|
||||
ROLE_KO = {
|
||||
"ceo": "대표", "evp": "전무", "svp": "상무", "technical_advisor": "기술고문",
|
||||
"director": "이사", "senior_manager": "부장", "deputy_manager": "차장",
|
||||
"manager": "과장", "assistant_manager": "대리", "junior": "주임",
|
||||
"associate": "사원", "intern": "인턴",
|
||||
}
|
||||
|
||||
|
||||
UNIT_KO = {"one_time": "1회", "project": "프로젝트", "monthly": "월"}
|
||||
|
||||
|
||||
def write_md(path, quote_no, date, prospect, items, tot, months, disclaimer):
|
||||
L = [f"# 견적서 (Pre-sales 추정) — {prospect}",
|
||||
"", f"- **견적번호**: {quote_no}", f"- **작성일**: {date}",
|
||||
f"- **대상**: {prospect}", f"- **공급자**: OurDigital (andrew.yim@ourdigital.org)",
|
||||
"", "## 견적 내역", "",
|
||||
"| 항목 | 근거 | 단위 | 수량 | 단가(범위) | 금액(범위) |",
|
||||
"|---|---|---|---:|---|---|"]
|
||||
for i in items:
|
||||
L.append(f"| {i['label']} | {i['reason']} | {UNIT_KO.get(i['unit'], i['unit'])} | {i['qty']} | "
|
||||
f"{won(i['unit_min'])}~{won(i['unit_max'])} | {won(i['amount_min'])}~{won(i['amount_max'])} |")
|
||||
L += ["", "## 합계 (범위)", "",
|
||||
f"- 일회성/프로젝트: **{won(tot['one_time_min'])} ~ {won(tot['one_time_max'])}**",
|
||||
f"- 월 운영({months}개월 기준): **{won(tot['monthly_min'])} ~ {won(tot['monthly_max'])}**",
|
||||
f"- 총계: **{won(tot['grand_min'])} ~ {won(tot['grand_max'])}**",
|
||||
"", f"> {disclaimer}"]
|
||||
def write_md(path, q):
|
||||
L = [f"# 견적서 — {q['prospect']}",
|
||||
"", f"- **제공 서비스**: {q['service']}",
|
||||
f"- **견적번호**: {q['quote_no']} · **작성일**: {q['date']} · **유효기간**: ~{q['valid_until']}",
|
||||
f"- **공급자**: {q['company']['legal_name']} (대표 {q['company']['ceo']}, {q['company']['contact']})",
|
||||
f"- **산정 기준**: SOW 기반 · 청구율 {int(q['billing_rate']*100)}% · 일 8시간/월 4주 · {q['terms']['vat']} · 지급 {q['terms']['payment']}",
|
||||
""]
|
||||
if q["scope"]["hours_multiplier"] != 1.0:
|
||||
L.append(f"> 포트폴리오 규모 반영: 프로퍼티 {q['scope']['properties_total']}개 기준 Technical/On-page 업무시간 ×{q['scope']['hours_multiplier']:g} (서브선형)")
|
||||
L.append("")
|
||||
L += ["## 견적 내역", "",
|
||||
"| 구분 | 세부 업무 | 담당 | 시간(h) | 합계 |",
|
||||
"|---|---|:--:|--:|--:|"]
|
||||
for m in q["modules"]:
|
||||
for i, t in enumerate(m["tasks"]):
|
||||
grp = m["name"] if i == 0 else ""
|
||||
mark = " *" if t["scaled"] else ""
|
||||
L.append(f"| {grp} | {t['task']}{mark} | {ROLE_KO.get(t['role'], t['role'])} | {t['hours']:g} | {won(t['amount'])} |")
|
||||
L.append(f"| | **{m['name']} 소계** | | | **{won(m['subtotal'])}** |")
|
||||
L += ["", "## 합계", "",
|
||||
f"- 합계: **{won(q['subtotal_sum'])}**",
|
||||
f"- **제안가(절사 적용): {won(q['proposal'])}** ({q['terms']['vat']})", ""]
|
||||
if q.get("tools"):
|
||||
L += ["## 별도 비용 (조달 — 인력비와 별도)", ""]
|
||||
for t in q["tools"]:
|
||||
L.append(f"- {t['label']}: {t['unit']} ${t['price_usd']} — {t['note']}")
|
||||
L.append("")
|
||||
L += ["---", f"> {q['disclaimer']}"]
|
||||
if any(t["scaled"] for m in q["modules"] for t in m["tasks"]):
|
||||
L.append("> \\* 포트폴리오 규모에 따라 업무시간이 스케일된 항목.")
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(L) + "\n")
|
||||
|
||||
|
||||
def write_xlsx(path, quote_no, date, prospect, items, tot, months, disclaimer):
|
||||
def write_xlsx(path, q):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "견적"
|
||||
hdr = PatternFill("solid", fgColor="11243D")
|
||||
hf = Font(color="FFFFFF", bold=True)
|
||||
ws.append([f"견적서 (Pre-sales 추정) — {prospect}"])
|
||||
ws.append([f"견적번호 {quote_no}", f"작성일 {date}", "공급자 OurDigital"])
|
||||
ws.append([f"견적서 — {q['prospect']} ({q['service']})"])
|
||||
ws.append([f"견적번호 {q['quote_no']}", f"작성일 {q['date']}", f"유효 ~{q['valid_until']}",
|
||||
f"청구율 {int(q['billing_rate']*100)}%", q["terms"]["vat"]])
|
||||
ws.append([])
|
||||
cols = ["항목", "근거", "단위", "수량", "단가 min", "단가 max", "금액 min", "금액 max"]
|
||||
cols = ["구분", "세부 업무", "담당", "시간(h)", "합계(원)"]
|
||||
ws.append(cols)
|
||||
hdr_row = ws.max_row
|
||||
for c in range(1, len(cols) + 1):
|
||||
cell = ws.cell(row=ws.max_row, column=c)
|
||||
cell.fill = hdr
|
||||
cell.font = hf
|
||||
for i in items:
|
||||
ws.append([i["label"], i["reason"], UNIT_KO.get(i["unit"], i["unit"]), i["qty"],
|
||||
i["unit_min"], i["unit_max"], i["amount_min"], i["amount_max"]])
|
||||
cell = ws.cell(row=hdr_row, column=c)
|
||||
cell.fill = PatternFill("solid", fgColor="11243D")
|
||||
cell.font = Font(color="FFFFFF", bold=True)
|
||||
for m in q["modules"]:
|
||||
for i, t in enumerate(m["tasks"]):
|
||||
ws.append([m["name"] if i == 0 else "", t["task"], ROLE_KO.get(t["role"], t["role"]),
|
||||
t["hours"], int(round(t["amount"]))])
|
||||
ws.append(["", f"{m['name']} 소계", "", "", int(round(m["subtotal"]))])
|
||||
ws.cell(row=ws.max_row, column=2).font = Font(bold=True)
|
||||
ws.cell(row=ws.max_row, column=5).font = Font(bold=True)
|
||||
ws.append([])
|
||||
ws.append(["일회성/프로젝트 합계", "", "", "", "", "", tot["one_time_min"], tot["one_time_max"]])
|
||||
ws.append([f"월 운영 합계 ({months}개월)", "", "", "", "", "", tot["monthly_min"], tot["monthly_max"]])
|
||||
ws.append(["총계", "", "", "", "", "", tot["grand_min"], tot["grand_max"]])
|
||||
ws.cell(row=ws.max_row, column=1).font = Font(bold=True)
|
||||
ws.append(["", "합계", "", "", int(round(q["subtotal_sum"]))])
|
||||
ws.append(["", "제안가(절사 적용)", "", "", int(q["proposal"])])
|
||||
ws.cell(row=ws.max_row, column=2).font = Font(bold=True)
|
||||
ws.cell(row=ws.max_row, column=5).font = Font(bold=True, color="C0392B")
|
||||
ws.append([])
|
||||
ws.append([disclaimer])
|
||||
widths = [34, 30, 8, 6, 12, 12, 14, 14]
|
||||
for idx, w in enumerate(widths, 1):
|
||||
ws.append([q["disclaimer"]])
|
||||
for idx, w in enumerate([22, 40, 8, 8, 16], 1):
|
||||
ws.column_dimensions[chr(64 + idx)].width = w
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Generate ranged 견적 from findings.json")
|
||||
ap = argparse.ArgumentParser(description="Effort-based 견적 from findings.json")
|
||||
ap.add_argument("--findings", required=True)
|
||||
ap.add_argument("--rate-card", required=True)
|
||||
ap.add_argument("--sow", required=True)
|
||||
ap.add_argument("--out-dir", default=".")
|
||||
ap.add_argument("--seq", type=int, default=1, help="quote sequence number (NNN)")
|
||||
ap.add_argument("--seq", type=int, default=1)
|
||||
ap.add_argument("--baseline", choices=["basic", "treatment"], default=None)
|
||||
ap.add_argument("--billing", type=float, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
with open(args.findings, encoding="utf-8") as fh:
|
||||
f = json.load(fh)
|
||||
with open(args.rate_card, encoding="utf-8") as fh:
|
||||
rate = yaml.safe_load(fh)
|
||||
with open(args.sow, encoding="utf-8") as fh:
|
||||
sow = yaml.safe_load(fh)
|
||||
|
||||
billing = args.billing if args.billing is not None else rate["billing_rate"]
|
||||
baseline = pick_baseline(f, args.baseline)
|
||||
modules, grand, mult, props, service = assemble(f, rate, sow, baseline, billing)
|
||||
|
||||
rounding = rate["rounding_unit"]
|
||||
proposal = int(math.floor(grand / rounding) * rounding)
|
||||
|
||||
prospect = f.get("prospect", {}).get("name", "(prospect)")
|
||||
date = f.get("prospect", {}).get("audit_date") or datetime.date.today().isoformat()
|
||||
year = date[:4]
|
||||
quote_no = f"{rate.get('quote_prefix', 'OD')}-{year}-{args.seq:03d}"
|
||||
disclaimer = rate["defaults"]["disclaimer_ko"]
|
||||
d0 = datetime.date.fromisoformat(date)
|
||||
valid_until = (d0 + datetime.timedelta(days=rate["terms"]["validity_days"])).isoformat()
|
||||
quote_no = f"{rate.get('quote_prefix', 'OD')}-{date[:4]}-{args.seq:03d}"
|
||||
|
||||
chosen = select_services(f)
|
||||
items, months = build_line_items(chosen, rate, f)
|
||||
tot = totals(items)
|
||||
disclaimer = ("본 견적은 공개 데이터 기반 사전 추정이며 표준 업무시간(SOW)·청구율 "
|
||||
f"{int(billing*100)}% 기준입니다. Search Console/Analytics 권한 확보 후 정밀 "
|
||||
"진단을 통해 과업 시간과 범위를 확정합니다. 외부 조달 항목은 인력비와 별도이며 조달 수수료 15%가 적용될 수 있습니다.")
|
||||
|
||||
q = {
|
||||
"quote_no": quote_no, "date": date, "valid_until": valid_until,
|
||||
"prospect": f.get("prospect", {}).get("name", "(prospect)"),
|
||||
"service": service, "baseline": baseline, "billing_rate": billing,
|
||||
"company": rate["company"], "terms": rate["terms"],
|
||||
"scope": {"properties_total": props,
|
||||
"subbrands_total": f.get("entity", {}).get("subbrands_total", 0),
|
||||
"hours_multiplier": mult},
|
||||
"modules": modules, "subtotal_sum": grand, "proposal": proposal,
|
||||
"rounding_unit": rounding,
|
||||
"tools": [dict(label=v["label"], unit=v["unit"], price_usd=v["price_usd"], note=v["note"])
|
||||
for v in rate.get("tools", {}).values()],
|
||||
"disclaimer": disclaimer,
|
||||
}
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
data_dir = os.path.join(args.out_dir, "data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
write_md(os.path.join(args.out_dir, "05_estimate_ko.md"), q)
|
||||
write_xlsx(os.path.join(args.out_dir, "05_estimate.xlsx"), q)
|
||||
with open(os.path.join(data_dir, "estimate.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump(q, fh, ensure_ascii=False, indent=2)
|
||||
|
||||
md_path = os.path.join(args.out_dir, "05_estimate_ko.md")
|
||||
xlsx_path = os.path.join(args.out_dir, "05_estimate.xlsx")
|
||||
json_path = os.path.join(data_dir, "estimate.json")
|
||||
|
||||
write_md(md_path, quote_no, date, prospect, items, tot, months, disclaimer)
|
||||
write_xlsx(xlsx_path, quote_no, date, prospect, items, tot, months, disclaimer)
|
||||
with open(json_path, "w", encoding="utf-8") as fh:
|
||||
json.dump({"quote_no": quote_no, "date": date, "prospect": prospect,
|
||||
"line_items": items, "totals": tot, "retainer_months": months,
|
||||
"disclaimer": disclaimer}, fh, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"견적 {quote_no}: {len(items)} line items | "
|
||||
f"one-time {won(tot['one_time_min'])}~{won(tot['one_time_max'])} | "
|
||||
f"monthly {won(tot['monthly_min'])}~{won(tot['monthly_max'])}/{months}mo")
|
||||
print(f"Wrote: {md_path}\n {xlsx_path}\n {json_path}")
|
||||
print(f"견적 {quote_no} [{baseline}] 제안가 {won(proposal)} (합계 {won(grand)}) "
|
||||
f"| 프로퍼티 {props} ×{mult:g} | 청구율 {int(billing*100)}%")
|
||||
for m in modules:
|
||||
print(f" {m['name']:24} {won(m['subtotal'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user