#!/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 [--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()