#!/usr/bin/env python3 """Effort-based 견적 generator for the ourdigital-presales-seo skill (Stage 5). 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 Usage: python estimate.py --findings data/findings.json \ --rate-card ../references/rate_card.yaml --sow ../references/sow_templates.yaml \ --out-dir --seq 1 [--baseline basic|treatment] [--billing 0.70] """ import argparse import datetime import json import math import os import yaml from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill def won(n): return f"{int(round(n)):,}원" 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]) 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 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, }) sub += amount modules.append({"name": mod["name"], "subtotal": sub, "tasks": tasks}) grand += sub return modules, grand, mult, props, tpl["service"] ROLE_KO = { "ceo": "대표", "evp": "전무", "svp": "상무", "technical_advisor": "기술고문", "director": "이사", "senior_manager": "부장", "deputy_manager": "차장", "manager": "과장", "assistant_manager": "대리", "junior": "주임", "associate": "사원", "intern": "인턴", } 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, q): wb = Workbook() ws = wb.active ws.title = "견적" 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 = ["구분", "세부 업무", "담당", "시간(h)", "합계(원)"] ws.append(cols) hdr_row = ws.max_row for c in range(1, len(cols) + 1): 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(["", "합계", "", "", 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([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="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) 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) date = f.get("prospect", {}).get("audit_date") or datetime.date.today().isoformat() 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}" 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) 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__": main()