Rebuild estimate engine to effort-based SOW model
Replace flat per-service ranges with OurDigital's real quoting model (role_rate × billing_rate 0.70 × standard hours), sourced from the 06_Working Template quotes: - rate_card.yaml: role rate card, billing/basis/terms, tools, scaling bands - sow_templates.yaml: basic + treatment task-hour templates - estimate.py: assemble SOW from findings, scale Technical/On-page hours by properties_total, 제안가 = 합계 floored to 500k - build_deck.py: estimate slide shows module 소계 + 제안가 (point) - findings_to_service.md / SKILL.md / DESIGN.md: synced to new model Validated: reproduces real Basic ₩10.5M and Treatment ₩25.0M exactly; SHR (25 properties) scales to ₩71.5M, L'Escape (1) = ₩25.0M. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -250,17 +250,16 @@ def main():
|
||||
[(body, 12, RGBColor(0x33, 0x3A, 0x45), False)],
|
||||
])
|
||||
|
||||
# 8) Estimate
|
||||
# 8) Estimate (effort-based: module subtotals + 제안가)
|
||||
s = blank(prs)
|
||||
header(s, "ESTIMATE", "예상 견적 (사전 추정 범위)")
|
||||
if EST:
|
||||
items = EST.get("line_items", [])
|
||||
rows = min(len(items) + 1, 9)
|
||||
tbl = s.shapes.add_table(rows, 3, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.45 * rows)).table
|
||||
tbl.columns[0].width = Inches(6.0)
|
||||
tbl.columns[1].width = Inches(2.0)
|
||||
tbl.columns[2].width = Inches(3.6)
|
||||
for j, htxt in enumerate(["항목", "단위", "금액(범위)"]):
|
||||
header(s, "ESTIMATE", "예상 견적 (사전 추정)")
|
||||
mods = (EST or {}).get("modules") if EST else None
|
||||
if mods:
|
||||
rows = len(mods) + 2 # header + modules + proposal
|
||||
tbl = s.shapes.add_table(rows, 2, Inches(0.85), Inches(2.0), Inches(11.6), Inches(0.5 * rows)).table
|
||||
tbl.columns[0].width = Inches(8.4)
|
||||
tbl.columns[1].width = Inches(3.2)
|
||||
for j, htxt in enumerate([f"{EST.get('service', 'SEO')} — 구분", "소계"]):
|
||||
c = tbl.cell(0, j)
|
||||
c.text = htxt
|
||||
c.fill.solid()
|
||||
@@ -268,20 +267,29 @@ def main():
|
||||
for para in c.text_frame.paragraphs:
|
||||
for r in para.runs:
|
||||
_style(r, 12, WHITE, True)
|
||||
unit_ko = {"one_time": "1회", "project": "프로젝트", "monthly": "월"}
|
||||
for i, it in enumerate(items[:rows - 1], 1):
|
||||
amt = f"{it['amount_min']:,}~{it['amount_max']:,}원"
|
||||
for j, v in enumerate([it["label"], unit_ko.get(it["unit"], it["unit"]), amt]):
|
||||
for i, m in enumerate(mods, 1):
|
||||
for j, v in enumerate([m["name"], f"{int(round(m['subtotal'])):,}원"]):
|
||||
c = tbl.cell(i, j)
|
||||
c.text = v
|
||||
for para in c.text_frame.paragraphs:
|
||||
for r in para.runs:
|
||||
_style(r, 11, NAVY, False)
|
||||
tot = EST.get("totals", {})
|
||||
textbox(s, 0.85, 2.0 + 0.45 * rows + 0.2, 11.6, 1.2, [
|
||||
[("총계(범위): ", 14, NAVY, True),
|
||||
(f"{tot.get('grand_min', 0):,} ~ {tot.get('grand_max', 0):,}원", 14, RED, True)],
|
||||
[(EST.get("disclaimer", ""), 10, GREY, False)],
|
||||
pr = len(mods) + 1
|
||||
for j, v in enumerate(["제안가 (절사 적용 · 부가세 별도)", f"{EST.get('proposal', 0):,}원"]):
|
||||
c = tbl.cell(pr, j)
|
||||
c.text = v
|
||||
c.fill.solid()
|
||||
c.fill.fore_color.rgb = LIGHT
|
||||
for para in c.text_frame.paragraphs:
|
||||
for r in para.runs:
|
||||
_style(r, 13 if j else 12, RED if j else NAVY, True)
|
||||
sc = EST.get("scope", {})
|
||||
note = f"청구율 {int(EST.get('billing_rate', 0.7) * 100)}% · 일8h/월4주 · SOW 기반"
|
||||
if sc.get("hours_multiplier", 1.0) != 1.0:
|
||||
note += f" · 프로퍼티 {sc.get('properties_total')}개 ×{sc['hours_multiplier']:g}"
|
||||
textbox(s, 0.85, 2.2 + 0.5 * rows, 11.6, 1.3, [
|
||||
[(note, 10, GREY, False)],
|
||||
[(EST.get("disclaimer", ""), 9, GREY, False)],
|
||||
])
|
||||
else:
|
||||
textbox(s, 0.85, 2.2, 11.6, 1.0, [[("견적 데이터(estimate.json) 미연결 — estimate.py 실행 후 재생성", 13, GREY, False)]])
|
||||
|
||||
Reference in New Issue
Block a user