#!/usr/bin/env python3 """Generate a ranged 견적 (estimate) from findings.json using the OurDigital rate card. Part of the ourdigital-presales-seo skill (Stage 5). Maps detected findings to rate-card service lines (see references/findings_to_service.md) and emits: - 05_estimate_ko.md (Korean line-item quote) - data/estimate.json (consumed by build_deck.py) - 05_estimate.xlsx (spreadsheet quote) Usage: python estimate.py --findings findings.json --rate-card ../references/rate_card.yaml \ --out-dir ./audits/2026-05-27-presales --seq 1 """ import argparse import datetime import json import os import yaml from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill def won(n): return f"{n:,}원" def select_services(f): """Return {service_key: [reasons]} based on findings signals.""" chosen = {} d = f.get("discovery", {}) t = f.get("technical", {}) e = f.get("entity", {}) m = f.get("measurement", {}) def need(key, reason): chosen.setdefault(key, []) if reason not in chosen[key]: chosen[key].append(reason) # Crawlability / indexation if d.get("sitemap_status", 200) != 200 or d.get("robots_sitemap_declared", True) is False: need("technical_audit", "sitemap/robots 색인 이슈") need("technical_remediation", "sitemap 복구·크롤성 개선") # Core Web Vitals cwv = t.get("cwv", {}) if (cwv.get("perf", 1) < 0.5 or cwv.get("cls", 0) > 0.1 or cwv.get("lcp_ms", 0) > 2500 or cwv.get("ttfb_ms", 0) > 600): need("technical_audit", "Core Web Vitals 취약") need("technical_remediation", "CWV(CLS/LCP/TTFB) 개선") # Schema / entity if (t.get("schema", {}).get("org") in ("bare", "none") or e.get("panel") != "hotel" or e.get("name_split") or e.get("legacy_contamination") or (e.get("subbrands_with_entity", 0) == 0 and e.get("subbrands_total", 0) > 0)): need("schema_build", "Organization/Hotel schema·브랜드 표기 정합") need("onpage_entity", "엔티티·서브브랜드 최적화") # Local hygiene = " ".join(d.get("url_hygiene", [])).lower() if ((e.get("properties_with_entity", 0) == 0 and e.get("properties_total", 0) > 0) or "local" in hygiene or "gbp" in hygiene or "dup_path" in hygiene): need("local_seo", "프로퍼티 로컬·GBP 정합") # Measurement if m.get("gsc_access") is False or m.get("ga4_access") is False: need("ga4_impl", "측정 환경(GA4) 구축") need("dashboard", "리포팅 대시보드 구축") if m.get("tag_gaps"): need("gtm_setup", "태그 관리(GTM) 구축") # On-page hygiene if t.get("meta_dupe") or t.get("title_i18n_mismatch") or t.get("hreflang") == "incomplete": need("onpage_entity", "Meta/Title/hreflang 정리") return chosen DRIVER_LABEL = {"properties_total": "프로퍼티", "subbrands_total": "서브브랜드"} def scope_multiplier(rate, key, f): """Sub-linear scope multiplier for a service, driven by portfolio size. Returns (multiplier, driver, count). count is floored at 1 (unknown→base). """ rule = rate.get("scaling", {}).get(key) if not rule: return 1.0, None, None driver = rule["driver"] count = max(int(f.get("entity", {}).get(driver, 0) or 0), 1) for max_count, mult in rule["bands"]: if count <= max_count: return float(mult), driver, count return 1.0, driver, count def build_line_items(chosen, rate, f): months = rate["defaults"]["retainer_months"] order = {"one_time": 0, "project": 1, "monthly": 2} items = [] for key, reasons in chosen.items(): svc = rate["services"][key] unit = svc["unit"] qty = months if unit == "monthly" else 1 mult, driver, count = scope_multiplier(rate, key, f) umin = int(round(svc["min"] * mult)) umax = int(round(svc["max"] * mult)) reason = "; ".join(reasons) scope_note = None if mult != 1.0: scope_note = f"{DRIVER_LABEL.get(driver, driver)} {count}개 기준 ×{mult:g}" reason = f"{reason} [{scope_note}]" items.append({ "key": key, "label": svc["label_ko"], "unit": unit, "qty": qty, "unit_min": umin, "unit_max": umax, "amount_min": umin * qty, "amount_max": umax * qty, "reason": reason, "scope_multiplier": mult, "scope_driver": driver, "scope_count": count, "scope_note": scope_note, }) items.sort(key=lambda x: order.get(x["unit"], 9)) return items, months def totals(items): one = [i for i in items if i["unit"] != "monthly"] mon = [i for i in items if i["unit"] == "monthly"] return { "one_time_min": sum(i["amount_min"] for i in one), "one_time_max": sum(i["amount_max"] for i in one), "monthly_min": sum(i["amount_min"] for i in mon), "monthly_max": sum(i["amount_max"] for i in mon), "grand_min": sum(i["amount_min"] for i in items), "grand_max": sum(i["amount_max"] for i in items), } UNIT_KO = {"one_time": "1회", "project": "프로젝트", "monthly": "월"} def write_md(path, quote_no, date, prospect, items, tot, months, disclaimer): L = [f"# 견적서 (Pre-sales 추정) — {prospect}", "", f"- **견적번호**: {quote_no}", f"- **작성일**: {date}", f"- **대상**: {prospect}", f"- **공급자**: OurDigital (andrew.yim@ourdigital.org)", "", "## 견적 내역", "", "| 항목 | 근거 | 단위 | 수량 | 단가(범위) | 금액(범위) |", "|---|---|---|---:|---|---|"] for i in items: L.append(f"| {i['label']} | {i['reason']} | {UNIT_KO.get(i['unit'], i['unit'])} | {i['qty']} | " f"{won(i['unit_min'])}~{won(i['unit_max'])} | {won(i['amount_min'])}~{won(i['amount_max'])} |") L += ["", "## 합계 (범위)", "", f"- 일회성/프로젝트: **{won(tot['one_time_min'])} ~ {won(tot['one_time_max'])}**", f"- 월 운영({months}개월 기준): **{won(tot['monthly_min'])} ~ {won(tot['monthly_max'])}**", f"- 총계: **{won(tot['grand_min'])} ~ {won(tot['grand_max'])}**", "", f"> {disclaimer}"] with open(path, "w", encoding="utf-8") as fh: fh.write("\n".join(L) + "\n") def write_xlsx(path, quote_no, date, prospect, items, tot, months, disclaimer): wb = Workbook() ws = wb.active ws.title = "견적" hdr = PatternFill("solid", fgColor="11243D") hf = Font(color="FFFFFF", bold=True) ws.append([f"견적서 (Pre-sales 추정) — {prospect}"]) ws.append([f"견적번호 {quote_no}", f"작성일 {date}", "공급자 OurDigital"]) ws.append([]) cols = ["항목", "근거", "단위", "수량", "단가 min", "단가 max", "금액 min", "금액 max"] ws.append(cols) for c in range(1, len(cols) + 1): cell = ws.cell(row=ws.max_row, column=c) cell.fill = hdr cell.font = hf for i in items: ws.append([i["label"], i["reason"], UNIT_KO.get(i["unit"], i["unit"]), i["qty"], i["unit_min"], i["unit_max"], i["amount_min"], i["amount_max"]]) ws.append([]) ws.append(["일회성/프로젝트 합계", "", "", "", "", "", tot["one_time_min"], tot["one_time_max"]]) ws.append([f"월 운영 합계 ({months}개월)", "", "", "", "", "", tot["monthly_min"], tot["monthly_max"]]) ws.append(["총계", "", "", "", "", "", tot["grand_min"], tot["grand_max"]]) ws.cell(row=ws.max_row, column=1).font = Font(bold=True) ws.append([]) ws.append([disclaimer]) widths = [34, 30, 8, 6, 12, 12, 14, 14] for idx, w in enumerate(widths, 1): ws.column_dimensions[chr(64 + idx)].width = w wb.save(path) def main(): ap = argparse.ArgumentParser(description="Generate ranged 견적 from findings.json") ap.add_argument("--findings", required=True) ap.add_argument("--rate-card", required=True) ap.add_argument("--out-dir", default=".") ap.add_argument("--seq", type=int, default=1, help="quote sequence number (NNN)") 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) prospect = f.get("prospect", {}).get("name", "(prospect)") date = f.get("prospect", {}).get("audit_date") or datetime.date.today().isoformat() year = date[:4] quote_no = f"{rate.get('quote_prefix', 'OD')}-{year}-{args.seq:03d}" disclaimer = rate["defaults"]["disclaimer_ko"] chosen = select_services(f) items, months = build_line_items(chosen, rate, f) tot = totals(items) os.makedirs(args.out_dir, exist_ok=True) data_dir = os.path.join(args.out_dir, "data") os.makedirs(data_dir, exist_ok=True) md_path = os.path.join(args.out_dir, "05_estimate_ko.md") xlsx_path = os.path.join(args.out_dir, "05_estimate.xlsx") json_path = os.path.join(data_dir, "estimate.json") write_md(md_path, quote_no, date, prospect, items, tot, months, disclaimer) write_xlsx(xlsx_path, quote_no, date, prospect, items, tot, months, disclaimer) with open(json_path, "w", encoding="utf-8") as fh: json.dump({"quote_no": quote_no, "date": date, "prospect": prospect, "line_items": items, "totals": tot, "retainer_months": months, "disclaimer": disclaimer}, fh, ensure_ascii=False, indent=2) print(f"견적 {quote_no}: {len(items)} line items | " f"one-time {won(tot['one_time_min'])}~{won(tot['one_time_max'])} | " f"monthly {won(tot['monthly_min'])}~{won(tot['monthly_max'])}/{months}mo") print(f"Wrote: {md_path}\n {xlsx_path}\n {json_path}") if __name__ == "__main__": main()