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>
87 lines
3.8 KiB
Python
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()
|