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>
This commit is contained in:
@@ -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 <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()
|
||||
Reference in New Issue
Block a user