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>
62 lines
2.2 KiB
Python
62 lines
2.2 KiB
Python
"""Coaching method: cost = Σ(lesson_type_price × hours) over a lesson plan.
|
||
|
||
Base lesson-type prices reproduce real quotes (GA4/GTM 중급 → ₩1,570,000).
|
||
Optional matrix mode prices by subject level (base × level_multiple).
|
||
Student-count discount is opt-in (real 1:1 quotes apply none).
|
||
"""
|
||
|
||
|
||
def _discount(rate, students, apply):
|
||
if not apply:
|
||
return 0.0
|
||
for mx, r in rate["coaching"]["student_discount"]["bands"]:
|
||
if students <= mx:
|
||
return float(r)
|
||
return 0.0 # 30+ → 별도 협의
|
||
|
||
|
||
def _unit_price(rate, lesson, mode):
|
||
c = rate["coaching"]
|
||
base = c["lesson_type_prices"][lesson["type"]]
|
||
if mode == "matrix":
|
||
lvl = c.get("subject_levels", {}).get(lesson.get("subject"))
|
||
if lvl:
|
||
base = base * c["level_multiple"].get(lvl, 1.0)
|
||
return base
|
||
|
||
|
||
def build(scope, rate, catalog):
|
||
c = rate["coaching"]
|
||
mode = scope.get("pricing_mode") or c.get("pricing_mode", "base")
|
||
if scope.get("lessons"):
|
||
lessons = scope["lessons"]
|
||
title = scope.get("course") or "맞춤 코칭"
|
||
else:
|
||
cname = scope.get("course")
|
||
courses = catalog.get("courses", {})
|
||
if cname not in courses:
|
||
raise SystemExit(f"course '{cname}' not in catalog {list(courses)}")
|
||
lessons = courses[cname]["lessons"]
|
||
title = courses[cname].get("title", cname)
|
||
|
||
items, subtotal = [], 0.0
|
||
for L in lessons:
|
||
up = _unit_price(rate, L, mode)
|
||
amt = up * L["hours"]
|
||
items.append({"subject": L.get("subject", ""), "title": L.get("title", ""),
|
||
"type": L["type"], "hours": L["hours"], "unit_price": up, "amount": amt})
|
||
subtotal += amt
|
||
|
||
students = scope.get("students", 1)
|
||
apply = scope.get("apply_student_discount", c["student_discount"]["apply_default"])
|
||
disc = _discount(rate, students, apply)
|
||
disc_amt = subtotal * disc
|
||
total = subtotal - disc_amt
|
||
return {
|
||
"kind": "coaching", "service": catalog["service"], "label": title,
|
||
"pricing_mode": mode, "students": students,
|
||
"discount_rate": disc, "discount_amount": disc_amt,
|
||
"lessons": items, "subtotal_sum": subtotal, "proposal": int(round(total)),
|
||
"stub": bool(catalog.get("_stub", False)),
|
||
}
|