Files
our-claude-skills/custom-skills/96-ourdigital-estimate-engine/scripts/estimate.py
Andrew Yim c9bdbb57f7 Extract ourdigital-estimate-engine; presales-seo now calls it
New skill 96-ourdigital-estimate-engine: method-aware quoting engine
(effort / coaching / procurement) with universal rate_card + per-service
catalog. Real catalogs: seo (effort), education (coaching); stubs:
digital_ads, digital_branding. Validated to reproduce real quotes —
SEO basic ₩10.5M / treatment ₩25.0M, SHR chain ₩29.5M, L'Escape basic
₩10.5M, GA4/GTM coaching ₩1,570,000, procurement +15%.

Refactor 95-ourdigital-presales-seo: remove rate_card.yaml, sow_templates.yaml,
estimate.py (migrated to engine); add findings_to_scope.py; Stage 5 now maps
findings→scope.json and calls the engine CLI. build_deck/kg_query unchanged;
end-to-end validated on SHR (29.5M) + deck renders engine estimate.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:54:11 +09:00

87 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""OurDigital estimate engine — method-aware 견적 generator (CLI dispatcher).
Loads a generic scope.json + the universal rate_card + the service catalog entry,
routes to the costing method (effort | coaching | procurement), enriches with
quote metadata/terms, and renders 05_estimate_ko.md / .xlsx / data/estimate.json.
Usage:
python estimate.py --rate-card references/rate_card.yaml --catalog-dir catalog \
--scope scope.json --out-dir <dir> [--seq N]
scope.json: see scope.schema.json. Consuming skills (e.g. ourdigital-presales-seo)
map their context into scope.json and call this CLI.
"""
import argparse
import datetime
import json
import os
import sys
import yaml
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from methods import coaching, effort, procurement # noqa: E402
import render # noqa: E402
METHODS = {"effort": effort, "coaching": coaching, "procurement": procurement}
DISCLAIMER = {
"effort": ("본 견적은 공개/사전 정보 기반 추정이며 표준 업무시간(SOW)·청구율 {billing}% 기준입니다. "
"권한 확보 후 정밀 진단을 통해 과업 시간과 범위를 확정합니다. 외부 조달 항목은 인력비와 별도, 조달 수수료 15%가 적용될 수 있습니다."),
"coaching": ("본 견적은 레슨 시간 기준으로 작성되었으며(대면 100,000원/h, 화상 80,000원/h 등), 1:1 레슨 전제입니다. "
"그룹 레슨·단체 워크숍/실습은 별도 협의가 필요합니다. 수업 구성은 사전상담으로 맞춤 조율됩니다."),
"procurement": "조달 물품/서비스 견적이며 조달-관리 수수료 15%를 포함합니다. 실제 공급가는 계약 시점에 확정됩니다.",
}
def main():
ap = argparse.ArgumentParser(description="OurDigital estimate engine")
ap.add_argument("--rate-card", required=True)
ap.add_argument("--catalog-dir", required=True)
ap.add_argument("--scope", required=True)
ap.add_argument("--out-dir", default=".")
ap.add_argument("--seq", type=int, default=None)
args = ap.parse_args()
with open(args.rate_card, encoding="utf-8") as fh:
rate = yaml.safe_load(fh)
with open(args.scope, encoding="utf-8") as fh:
scope = json.load(fh)
service = scope["service"]
cat_path = os.path.join(args.catalog_dir, f"{service}.yaml")
if not os.path.exists(cat_path):
sys.exit(f"ERROR: no catalog for service '{service}' at {cat_path}")
with open(cat_path, encoding="utf-8") as fh:
catalog = yaml.safe_load(fh)
method = scope.get("method") or catalog.get("method")
if method not in METHODS:
sys.exit(f"ERROR: unknown method '{method}' (have {list(METHODS)})")
q = METHODS[method].build(scope, rate, catalog)
# enrich with universal metadata
prospect = scope.get("prospect", {})
date = prospect.get("audit_date") or datetime.date.today().isoformat()
d0 = datetime.date.fromisoformat(date)
seq = args.seq if args.seq is not None else scope.get("seq", 1)
q.update({
"quote_no": f"{rate.get('quote_prefix', 'OD')}-{date[:4]}-{seq:03d}",
"date": date,
"valid_until": (d0 + datetime.timedelta(days=rate["terms"]["validity_days"])).isoformat(),
"prospect": prospect, "company": rate["company"], "terms": rate["terms"],
"disclaimer": DISCLAIMER[method].format(billing=int(q.get("billing_rate", rate["billing_rate"]) * 100)),
})
render.write_all(q, args.out_dir)
print(f"견적 {q['quote_no']} [{service}/{method}"
+ (f"/{q['tier']}" if q['kind'] == 'effort' else "")
+ f"] 제안가 {q['proposal']:,}원 (합계 {int(round(q['subtotal_sum'])):,}원)"
+ (" ⚠STUB" if q.get("stub") else ""))
if __name__ == "__main__":
main()