initial version
This commit is contained in:
713
tools/oci_bizcase_gen.py
Normal file
713
tools/oci_bizcase_gen.py
Normal file
@@ -0,0 +1,713 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OCI Deal Accelerator — Business Case Deck Generator (.pptx)
|
||||
|
||||
Produces an 8-10 slide business case deck using the Oracle FY26 official
|
||||
PowerPoint template layouts. Target audience: customer CxO / decision-makers.
|
||||
|
||||
Usage:
|
||||
python oci_bizcase_gen.py --spec business-case.yaml --output business-case.pptx
|
||||
|
||||
Or import and use programmatically:
|
||||
from oci_bizcase_gen import BusinessCaseDeckGenerator
|
||||
gen = BusinessCaseDeckGenerator.from_spec(spec)
|
||||
gen.save("business-case.pptx")
|
||||
"""
|
||||
|
||||
import yaml
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
|
||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Business Case Deck Generator
|
||||
# ============================================================
|
||||
|
||||
class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
"""Generate business case decks using Oracle FY26 template layouts."""
|
||||
|
||||
# Use Teal accent for business case slides (finance / CxO audience)
|
||||
TITLE_ACCENT_COLOR = Colors.TEAL
|
||||
|
||||
# ================================================================
|
||||
# Slide Methods
|
||||
# ================================================================
|
||||
|
||||
def add_cover_slide(self, customer: str, subtitle: str = "",
|
||||
prepared_by: str = "", date: str = ""):
|
||||
"""Slide 1: Cover using Dark Title_Pillar layout."""
|
||||
slide = self._add_layout_slide(Layouts.COVER_DARK)
|
||||
self._set_placeholder(slide, 0, customer) # Title
|
||||
self._set_placeholder(slide, 33, subtitle or "Business Case for Oracle Cloud Infrastructure")
|
||||
date_str = date or datetime.now().strftime("%B %Y")
|
||||
self._set_placeholder(slide, 35, date_str)
|
||||
if prepared_by:
|
||||
self._set_placeholder(slide, 34, f"Prepared by: {prepared_by}")
|
||||
|
||||
def add_executive_summary_slide(self, statement: str):
|
||||
"""Slide 2: Executive Summary — controlled typography on blank slide."""
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
self._add_title_bar(slide, "Executive Summary")
|
||||
|
||||
# Body — large enough to read, small enough to fit the paragraph
|
||||
body_box = slide.shapes.add_textbox(Inches(0.8), Inches(1.3), Inches(11.7), Inches(4.5))
|
||||
tf = body_box.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.text = statement
|
||||
p.font.size = Pt(18)
|
||||
p.font.name = self.FONT
|
||||
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||
p.space_after = Pt(6)
|
||||
|
||||
def add_business_drivers_slide(self, drivers: list):
|
||||
"""Slide 3: Business Drivers — numbered cards on blank slide.
|
||||
|
||||
drivers: list of up to 3 strings, each a driver statement (may include newline
|
||||
separating a bold headline from detail text).
|
||||
"""
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
self._add_title_bar(slide, "Business Drivers")
|
||||
|
||||
card_colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST]
|
||||
card_y_start = Inches(1.3)
|
||||
available_h = Inches(5.8)
|
||||
n = min(len(drivers), 3)
|
||||
card_h = available_h / n if n else available_h
|
||||
card_h = min(card_h, Inches(2.1))
|
||||
|
||||
for i, driver in enumerate(drivers[:3]):
|
||||
color = card_colors[i % len(card_colors)]
|
||||
y = card_y_start + i * card_h
|
||||
|
||||
# Left accent bar
|
||||
accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.8), y + Inches(0.1), Inches(0.08), card_h - Inches(0.2))
|
||||
accent.fill.solid()
|
||||
accent.fill.fore_color.rgb = color
|
||||
accent.line.fill.background()
|
||||
|
||||
# Number badge
|
||||
self._add_textbox(
|
||||
slide, Inches(1.05), y + Inches(0.12), Inches(0.5), Inches(0.45),
|
||||
text=f"0{i+1}", font_size=18, bold=True, color=color,
|
||||
)
|
||||
|
||||
# Split driver into headline + detail (split on \n if present)
|
||||
parts = driver.split("\n", 1)
|
||||
headline = parts[0].strip()
|
||||
detail = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
# Headline
|
||||
self._add_textbox(
|
||||
slide, Inches(1.6), y + Inches(0.1), Inches(11.0), Inches(0.5),
|
||||
text=headline, font_size=15, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
# Detail
|
||||
if detail:
|
||||
self._add_textbox(
|
||||
slide, Inches(1.6), y + Inches(0.6), Inches(11.0), card_h - Inches(0.75),
|
||||
text=detail, font_size=13, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_tco_slide(self, tco: dict):
|
||||
"""Slide 4: TCO Comparison — table on blank slide.
|
||||
|
||||
tco: dict from business-case.yaml with current_state and proposed_oci.
|
||||
"""
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
self._add_title_bar(slide, "Total Cost of Ownership")
|
||||
|
||||
# Subtitle
|
||||
horizon = tco.get("horizon_years", 3)
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(1.1), Inches(11), Inches(0.4),
|
||||
text=f"{horizon}-Year Comparison | Current State vs Oracle Cloud Infrastructure",
|
||||
font_size=13, color=Colors.TEAL, bold=True,
|
||||
)
|
||||
|
||||
current = tco.get("current_state", {})
|
||||
proposed = tco.get("proposed_oci", {})
|
||||
savings = tco.get("savings", {})
|
||||
|
||||
rows_data = [
|
||||
("Infrastructure", current.get("annual_infrastructure", 0), proposed.get("annual_cloud_consumption", 0)),
|
||||
("Licensing / Support", current.get("annual_licensing", 0), proposed.get("annual_licensing", 0)),
|
||||
("Operations (People)", current.get("annual_operations", 0), proposed.get("annual_operations", 0)),
|
||||
("Downtime Cost", current.get("annual_downtime_cost", 0), proposed.get("annual_downtime_cost", 0)),
|
||||
("Compliance", current.get("annual_compliance_cost", 0), proposed.get("annual_compliance_cost", 0)),
|
||||
]
|
||||
# Filter out zero rows
|
||||
rows_data = [(n, c, o) for n, c, o in rows_data if c or o]
|
||||
|
||||
# Add migration one-time
|
||||
migration = proposed.get("migration_one_time", 0)
|
||||
|
||||
num_rows = len(rows_data) + 3 # header + rows + annual total + horizon total
|
||||
table = self._add_table(
|
||||
slide, num_rows, 4,
|
||||
Inches(0.8), Inches(1.6),
|
||||
Inches(11.7), Inches(0.42 * num_rows),
|
||||
)
|
||||
table.columns[0].width = Inches(3.5)
|
||||
table.columns[1].width = Inches(2.7)
|
||||
table.columns[2].width = Inches(2.7)
|
||||
table.columns[3].width = Inches(2.8)
|
||||
|
||||
# Header
|
||||
for j, h in enumerate(["Cost Category", "Current (Annual)", "OCI (Annual)", "Savings"]):
|
||||
self._style_table_cell(
|
||||
table.cell(0, j), h, font_size=11, bold=True,
|
||||
color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER if j > 0 else PP_ALIGN.LEFT,
|
||||
)
|
||||
|
||||
# Data rows
|
||||
for i, (name, curr, oci) in enumerate(rows_data):
|
||||
row = i + 1
|
||||
bg = Colors.TABLE_ALT_ROW if row % 2 == 0 else None
|
||||
curr_val = curr if isinstance(curr, (int, float)) else 0
|
||||
oci_val = oci if isinstance(oci, (int, float)) else 0
|
||||
save_val = curr_val - oci_val
|
||||
self._style_table_cell(table.cell(row, 0), name, font_size=10, bg_color=bg)
|
||||
self._style_table_cell(table.cell(row, 1), f"${curr_val:,.0f}", font_size=10, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(row, 2), f"${oci_val:,.0f}", font_size=10, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
save_color = Colors.SUCCESS if save_val > 0 else Colors.ERROR
|
||||
self._style_table_cell(table.cell(row, 3), f"${save_val:,.0f}", font_size=10, bold=True, color=save_color, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
|
||||
# Annual total row
|
||||
total_row = len(rows_data) + 1
|
||||
ann_current = current.get("total_annual", 0) or sum(r[1] for r in rows_data if isinstance(r[1], (int, float)))
|
||||
ann_oci = proposed.get("total_annual", 0) or sum(r[2] for r in rows_data if isinstance(r[2], (int, float)))
|
||||
ann_savings = savings.get("annual", 0) or (ann_current - ann_oci)
|
||||
self._style_table_cell(table.cell(total_row, 0), "TOTAL ANNUAL", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL)
|
||||
self._style_table_cell(table.cell(total_row, 1), f"${ann_current:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(total_row, 2), f"${ann_oci:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(total_row, 3), f"${ann_savings:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL, alignment=PP_ALIGN.RIGHT)
|
||||
|
||||
# Horizon total row
|
||||
h_row = total_row + 1
|
||||
h_current = current.get("total_over_horizon", 0) or (ann_current * horizon)
|
||||
h_oci = proposed.get("total_over_horizon", 0) or (ann_oci * horizon + migration)
|
||||
h_savings = savings.get("over_horizon", 0) or (h_current - h_oci)
|
||||
pct = savings.get("percentage", 0) or (h_savings / h_current * 100 if h_current else 0)
|
||||
self._style_table_cell(table.cell(h_row, 0), f"TOTAL {horizon}-YEAR", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG)
|
||||
self._style_table_cell(table.cell(h_row, 1), f"${h_current:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(h_row, 2), f"${h_oci:,.0f}", font_size=11, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(h_row, 3), f"${h_savings:,.0f} ({pct:.0f}%)", font_size=11, bold=True, color=Colors.GOLD, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
|
||||
|
||||
# Migration note
|
||||
if migration:
|
||||
note_y = Inches(1.6) + Inches(0.42 * num_rows) + Inches(0.15)
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), note_y, Inches(11), Inches(0.3),
|
||||
text=f"* Includes one-time migration investment of ${migration:,.0f} in Year 1",
|
||||
font_size=9, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
# Assumptions
|
||||
assumptions = tco.get("assumptions", [])
|
||||
if assumptions:
|
||||
a_y = Inches(1.6) + Inches(0.42 * num_rows) + Inches(0.45)
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), a_y, Inches(11), Inches(0.25),
|
||||
text="Assumptions:", font_size=9, bold=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
for idx, a in enumerate(assumptions[:4]):
|
||||
self._add_textbox(
|
||||
slide, Inches(1.0), a_y + Inches(0.25 + idx * 0.22),
|
||||
Inches(11), Inches(0.22),
|
||||
text=f"• {a}", font_size=8, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_roi_slide(self, roi: dict, headline: str = ""):
|
||||
"""Slide 5: ROI — centered big number + 3 supporting metrics."""
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
self._add_title_bar(slide, "Return on Investment")
|
||||
|
||||
# Big centered ROI number
|
||||
pct = roi.get("three_year_roi_pct", 0)
|
||||
metric_text = headline or (f"{pct:.0f}%" if pct else "—")
|
||||
big_box = slide.shapes.add_textbox(Inches(0.8), Inches(1.4), Inches(11.7), Inches(2.4))
|
||||
big_box.text_frame.word_wrap = False
|
||||
p = big_box.text_frame.paragraphs[0]
|
||||
p.text = metric_text
|
||||
p.font.size = Pt(96)
|
||||
p.font.bold = True
|
||||
p.font.name = self.FONT_HEADING
|
||||
p.font.color.rgb = Colors.TEAL
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
|
||||
# Label under big number
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(3.75), Inches(11.7), Inches(0.4),
|
||||
text="3-Year Return on Investment", font_size=16,
|
||||
color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
# 3 supporting metric boxes
|
||||
payback = roi.get("payback_months", 0)
|
||||
investment = roi.get("total_investment", 0)
|
||||
benefit = roi.get("annual_net_benefit", 0)
|
||||
metrics = []
|
||||
if payback:
|
||||
metrics.append((f"{payback} months", "Payback Period"))
|
||||
if investment:
|
||||
metrics.append((f"${investment:,.0f}", "Total Investment"))
|
||||
if benefit:
|
||||
metrics.append((f"${benefit:,.0f}/yr", "Annual Net Benefit"))
|
||||
|
||||
box_w = Inches(3.5)
|
||||
gap = Inches(0.45)
|
||||
total_w = box_w * len(metrics) + gap * (len(metrics) - 1)
|
||||
x_start = (Inches(13.33) - total_w) / 2
|
||||
y_box = Inches(4.4)
|
||||
|
||||
for j, (value, label) in enumerate(metrics):
|
||||
x = x_start + j * (box_w + gap)
|
||||
# Background
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y_box, box_w, Inches(1.4))
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
bg.line.color.rgb = Colors.MUTED_TEAL
|
||||
|
||||
# Value
|
||||
self._add_textbox(
|
||||
slide, x, y_box + Inches(0.15), box_w, Inches(0.7),
|
||||
text=value, font_size=26, bold=True, color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
# Label
|
||||
self._add_textbox(
|
||||
slide, x, y_box + Inches(0.85), box_w, Inches(0.45),
|
||||
text=label, font_size=11, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
def add_value_drivers_slide(self, drivers: list):
|
||||
"""Slide 6: Value Drivers — 4 categories on blank slide.
|
||||
|
||||
drivers: list of {"category": str, "title": str, "description": str, "quantified": str}
|
||||
"""
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
self._add_title_bar(slide, "Value Drivers")
|
||||
|
||||
# 4 value cards in a 2x2 grid
|
||||
card_colors = [Colors.TEAL, Colors.FOREST, Colors.BURNT_ORANGE, Colors.ORACLE_RED]
|
||||
positions = [
|
||||
(Inches(0.8), Inches(1.5)), # top-left
|
||||
(Inches(6.8), Inches(1.5)), # top-right
|
||||
(Inches(0.8), Inches(4.3)), # bottom-left
|
||||
(Inches(6.8), Inches(4.3)), # bottom-right
|
||||
]
|
||||
card_w, card_h = Inches(5.5), Inches(2.5)
|
||||
|
||||
for i, driver in enumerate(drivers[:4]):
|
||||
x, y = positions[i]
|
||||
color = card_colors[i % len(card_colors)]
|
||||
|
||||
# Color accent bar
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Inches(0.08), card_h)
|
||||
bar.fill.solid()
|
||||
bar.fill.fore_color.rgb = color
|
||||
bar.line.fill.background()
|
||||
|
||||
# Title
|
||||
title = driver.get("title", driver.get("category", "").replace("_", " ").title())
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.25), y + Inches(0.1),
|
||||
card_w - Inches(0.3), Inches(0.4),
|
||||
text=title, font_size=16, bold=True, color=color,
|
||||
)
|
||||
|
||||
# Quantified value (big)
|
||||
quantified = driver.get("quantified", "")
|
||||
if quantified:
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.25), y + Inches(0.55),
|
||||
card_w - Inches(0.3), Inches(0.5),
|
||||
text=quantified, font_size=22, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
|
||||
# Description
|
||||
desc = driver.get("description", "")
|
||||
if desc:
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.25), y + Inches(1.2),
|
||||
card_w - Inches(0.3), Inches(1.0),
|
||||
text=desc, font_size=11, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_risk_slide(self, migration_risks: list, do_nothing_risks: list):
|
||||
"""Slide 7: Risk Assessment — two-column table layout."""
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
self._add_title_bar(slide, "Risk Assessment")
|
||||
|
||||
col_w = Inches(5.6)
|
||||
col_gap = Inches(0.5)
|
||||
left_x = Inches(0.8)
|
||||
right_x = left_x + col_w + col_gap
|
||||
header_y = Inches(1.2)
|
||||
content_start_y = Inches(1.75)
|
||||
row_h = Inches(0.8) # height per risk block (title + detail)
|
||||
slide_h = Inches(7.1)
|
||||
|
||||
# Stretch rows to fill available slide height
|
||||
n_rows = max(len(migration_risks[:5]), len(do_nothing_risks[:5]))
|
||||
if n_rows > 0:
|
||||
available = slide_h - content_start_y
|
||||
row_h = min(Inches(1.8), available / n_rows)
|
||||
|
||||
# Column header backgrounds
|
||||
for x, color, label in [
|
||||
(left_x, Colors.TEAL, "✓ Migration Risks (Mitigated)"),
|
||||
(right_x, Colors.ERROR, "⚠ Risks of Inaction"),
|
||||
]:
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, header_y, col_w, Inches(0.45))
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = color
|
||||
bg.line.fill.background()
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.15), header_y + Inches(0.05), col_w - Inches(0.2), Inches(0.38),
|
||||
text=label, font_size=13, bold=True, color=Colors.WHITE,
|
||||
)
|
||||
|
||||
# Left column: migration risks
|
||||
for i, risk in enumerate(migration_risks[:5]):
|
||||
r_text = risk.get("risk", str(risk)) if isinstance(risk, dict) else str(risk)
|
||||
mitigation = risk.get("mitigation", "") if isinstance(risk, dict) else ""
|
||||
y = content_start_y + i * row_h
|
||||
|
||||
# Alternating row background
|
||||
if i % 2 == 0:
|
||||
row_bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left_x, y, col_w, row_h)
|
||||
row_bg.fill.solid()
|
||||
row_bg.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
row_bg.line.fill.background()
|
||||
|
||||
self._add_textbox(
|
||||
slide, left_x + Inches(0.15), y + Inches(0.07), col_w - Inches(0.25), Inches(0.4),
|
||||
text=f"• {r_text}", font_size=12, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
if mitigation:
|
||||
self._add_textbox(
|
||||
slide, left_x + Inches(0.25), y + Inches(0.45), col_w - Inches(0.35), row_h - Inches(0.48),
|
||||
text=f"Mitigation: {mitigation}", font_size=11,
|
||||
italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
# Right column: do-nothing risks
|
||||
for i, risk in enumerate(do_nothing_risks[:5]):
|
||||
r_text = risk.get("risk", str(risk)) if isinstance(risk, dict) else str(risk)
|
||||
impact = risk.get("impact", "") if isinstance(risk, dict) else ""
|
||||
timeline = risk.get("timeline", "") if isinstance(risk, dict) else ""
|
||||
y = content_start_y + i * row_h
|
||||
|
||||
if i % 2 == 0:
|
||||
row_bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, right_x, y, col_w, row_h)
|
||||
row_bg.fill.solid()
|
||||
row_bg.fill.fore_color.rgb = RGBColor(0xFF, 0xF3, 0xF3)
|
||||
row_bg.line.fill.background()
|
||||
|
||||
self._add_textbox(
|
||||
slide, right_x + Inches(0.15), y + Inches(0.07), col_w - Inches(0.25), Inches(0.4),
|
||||
text=f"• {r_text}", font_size=12, bold=True, color=Colors.ERROR,
|
||||
)
|
||||
detail_parts = []
|
||||
if impact:
|
||||
detail_parts.append(f"Impact: {impact}")
|
||||
if timeline:
|
||||
detail_parts.append(f"By: {timeline}")
|
||||
if detail_parts:
|
||||
self._add_textbox(
|
||||
slide, right_x + Inches(0.25), y + Inches(0.45), col_w - Inches(0.35), row_h - Inches(0.48),
|
||||
text=" | ".join(detail_parts), font_size=10,
|
||||
italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_roadmap_slide(self, phases: list, total_duration: str = ""):
|
||||
"""Slide 8: Implementation Roadmap — timeline bars filling the slide."""
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
title = "Implementation Roadmap"
|
||||
if total_duration:
|
||||
title += f" — {total_duration}"
|
||||
self._add_title_bar(slide, title)
|
||||
|
||||
phase_colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST, Colors.ORACLE_RED]
|
||||
content_start = Inches(1.25)
|
||||
available_h = Inches(5.9)
|
||||
n = min(len(phases), 4)
|
||||
row_h = available_h / n if n else available_h
|
||||
|
||||
bar_left = Inches(3.6)
|
||||
bar_w = Inches(9.1)
|
||||
label_w = Inches(2.6)
|
||||
|
||||
for i, phase in enumerate(phases[:4]):
|
||||
color = phase_colors[i % len(phase_colors)]
|
||||
y = content_start + i * row_h
|
||||
name = phase.get("name", f"Phase {i+1}")
|
||||
duration = phase.get("duration", "")
|
||||
deliverables = phase.get("deliverables", [])
|
||||
quick_wins = phase.get("quick_wins", [])
|
||||
|
||||
# Alternating row background
|
||||
if i % 2 == 0:
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.8), y, Inches(12.0), row_h - Inches(0.08))
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
bg.line.fill.background()
|
||||
|
||||
# Phase number circle accent
|
||||
dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(0.82), y + (row_h - Inches(0.38)) / 2, Inches(0.38), Inches(0.38))
|
||||
dot.fill.solid()
|
||||
dot.fill.fore_color.rgb = color
|
||||
dot.line.fill.background()
|
||||
dot.text_frame.paragraphs[0].text = str(i + 1)
|
||||
dot.text_frame.paragraphs[0].font.size = Pt(11)
|
||||
dot.text_frame.paragraphs[0].font.bold = True
|
||||
dot.text_frame.paragraphs[0].font.color.rgb = Colors.WHITE
|
||||
dot.text_frame.paragraphs[0].font.name = self.FONT
|
||||
dot.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
|
||||
|
||||
# Phase name
|
||||
self._add_textbox(
|
||||
slide, Inches(1.35), y + Inches(0.08), label_w, Inches(0.45),
|
||||
text=name, font_size=14, bold=True, color=color,
|
||||
)
|
||||
|
||||
# Duration bar
|
||||
bar_h = Inches(0.42)
|
||||
bar_y = y + (row_h - bar_h) / 2
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, bar_left, bar_y, bar_w * 0.55, bar_h)
|
||||
bar.fill.solid()
|
||||
bar.fill.fore_color.rgb = color
|
||||
bar.line.fill.background()
|
||||
bar.text_frame.paragraphs[0].text = duration
|
||||
bar.text_frame.paragraphs[0].font.size = Pt(12)
|
||||
bar.text_frame.paragraphs[0].font.bold = True
|
||||
bar.text_frame.paragraphs[0].font.color.rgb = Colors.WHITE
|
||||
bar.text_frame.paragraphs[0].font.name = self.FONT
|
||||
bar.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER
|
||||
|
||||
# Deliverables — listed to the right of the bar
|
||||
items = quick_wins + deliverables
|
||||
if items:
|
||||
items_text = " • ".join(items[:3])
|
||||
self._add_textbox(
|
||||
slide, bar_left + bar_w * 0.57, bar_y - Inches(0.02),
|
||||
bar_w * 0.43, bar_h + Inches(0.05),
|
||||
text=items_text, font_size=11, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_recommendation_slide(self, summary: str, next_steps: list = None):
|
||||
"""Slide 9: Recommendation — dark blank slide with controlled fonts."""
|
||||
slide = self._add_blank_slide(dark=True)
|
||||
|
||||
# "Recommendation" label in gold
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(0.45), Inches(11.7), Inches(0.5),
|
||||
text="Our Recommendation", font_size=13, bold=True,
|
||||
color=Colors.GOLD, font_name=self.FONT,
|
||||
)
|
||||
|
||||
# Gold accent bar
|
||||
acc = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.8), Inches(1.0), Inches(11.7), Inches(0.05))
|
||||
acc.fill.solid()
|
||||
acc.fill.fore_color.rgb = Colors.GOLD
|
||||
acc.line.fill.background()
|
||||
|
||||
# Main recommendation statement — 20pt, white, readable
|
||||
rec_box = slide.shapes.add_textbox(Inches(0.8), Inches(1.2), Inches(11.7), Inches(2.2))
|
||||
tf = rec_box.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.text = summary
|
||||
p.font.size = Pt(20)
|
||||
p.font.bold = True
|
||||
p.font.name = self.FONT_HEADING
|
||||
p.font.color.rgb = Colors.WHITE
|
||||
|
||||
# Next steps section
|
||||
if next_steps:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(3.6), Inches(4.0), Inches(0.4),
|
||||
text="NEXT STEPS", font_size=11, bold=True,
|
||||
color=Colors.GOLD,
|
||||
)
|
||||
# Divider
|
||||
div = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.8), Inches(4.05), Inches(11.7), Inches(0.03))
|
||||
div.fill.solid()
|
||||
div.fill.fore_color.rgb = RGBColor(0x55, 0x50, 0x4C)
|
||||
div.line.fill.background()
|
||||
|
||||
y = Inches(4.15)
|
||||
for i, step in enumerate(next_steps[:4]):
|
||||
action = step.get("action", str(step)) if isinstance(step, dict) else str(step)
|
||||
owner = step.get("owner", "") if isinstance(step, dict) else ""
|
||||
deadline = step.get("deadline", "") if isinstance(step, dict) else ""
|
||||
|
||||
# Number
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), y, Inches(0.4), Inches(0.5),
|
||||
text=str(i + 1), font_size=13, bold=True, color=Colors.GOLD,
|
||||
)
|
||||
# Action text
|
||||
action_text = action
|
||||
meta_parts = []
|
||||
if owner:
|
||||
meta_parts.append(owner)
|
||||
if deadline:
|
||||
meta_parts.append(deadline)
|
||||
meta = f" [{' · '.join(meta_parts)}]" if meta_parts else ""
|
||||
self._add_textbox(
|
||||
slide, Inches(1.25), y, Inches(11.2), Inches(0.5),
|
||||
text=action_text + meta, font_size=13, color=Colors.WHITE,
|
||||
)
|
||||
y += Inches(0.6)
|
||||
|
||||
# ================================================================
|
||||
# Build from YAML spec
|
||||
# ================================================================
|
||||
|
||||
@classmethod
|
||||
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "BusinessCaseDeckGenerator":
|
||||
"""Build a complete business case deck from a YAML specification."""
|
||||
bc = spec.get("business_case", spec) # Support both wrapped and unwrapped
|
||||
gen = cls(template=template)
|
||||
|
||||
# Slide 1: Cover
|
||||
gen.add_cover_slide(
|
||||
customer=bc.get("customer_name", ""),
|
||||
subtitle="Business Case for Oracle Cloud Infrastructure",
|
||||
prepared_by=bc.get("prepared_by", ""),
|
||||
date=bc.get("date", ""),
|
||||
)
|
||||
|
||||
# Slide 2: Executive Summary
|
||||
exec_summary = bc.get("executive_summary", "")
|
||||
if exec_summary:
|
||||
gen.add_executive_summary_slide(exec_summary)
|
||||
|
||||
# Slide 3: Business Drivers
|
||||
drivers_data = bc.get("drivers", {})
|
||||
if drivers_data:
|
||||
driver_statements = []
|
||||
primary = drivers_data.get("primary", "")
|
||||
urgency = drivers_data.get("urgency", "")
|
||||
coi = drivers_data.get("cost_of_inaction", {})
|
||||
|
||||
if primary:
|
||||
driver_statements.append(f"Primary Driver: {primary.replace('_', ' ').title()}\n{urgency}" if urgency else primary.replace('_', ' ').title())
|
||||
if coi.get("financial"):
|
||||
driver_statements.append(f"Financial Impact of Inaction\n{coi['financial']}")
|
||||
if coi.get("operational"):
|
||||
driver_statements.append(f"Operational Impact\n{coi['operational']}")
|
||||
elif coi.get("strategic"):
|
||||
driver_statements.append(f"Strategic Risk\n{coi['strategic']}")
|
||||
|
||||
if driver_statements:
|
||||
gen.add_business_drivers_slide(driver_statements)
|
||||
|
||||
# Slide 4: TCO Comparison
|
||||
tco = bc.get("tco", {})
|
||||
if tco and (tco.get("current_state") or tco.get("proposed_oci")):
|
||||
gen.add_tco_slide(tco)
|
||||
|
||||
# Slide 5: ROI
|
||||
roi = bc.get("roi", {})
|
||||
if roi and any(roi.get(k) for k in ["three_year_roi_pct", "payback_months", "total_investment"]):
|
||||
gen.add_roi_slide(roi)
|
||||
|
||||
# Slide 6: Value Drivers
|
||||
value_drivers = bc.get("value_drivers", [])
|
||||
if value_drivers:
|
||||
gen.add_value_drivers_slide(value_drivers)
|
||||
|
||||
# Slide 7: Risk Assessment
|
||||
risks = bc.get("risks", {})
|
||||
migration_risks = risks.get("migration_risks", [])
|
||||
do_nothing_risks = risks.get("do_nothing_risks", [])
|
||||
if migration_risks or do_nothing_risks:
|
||||
gen.add_risk_slide(migration_risks, do_nothing_risks)
|
||||
|
||||
# Slide 8: Roadmap
|
||||
roadmap = bc.get("roadmap", {})
|
||||
if roadmap.get("phases"):
|
||||
gen.add_roadmap_slide(
|
||||
phases=roadmap["phases"],
|
||||
total_duration=roadmap.get("total_duration", ""),
|
||||
)
|
||||
|
||||
# Slide 9: Recommendation
|
||||
rec = bc.get("recommendation", {})
|
||||
if rec:
|
||||
summary = rec.get("summary", "")
|
||||
if not summary and rec.get("commitment_amount"):
|
||||
summary = f"Recommended: {rec['commitment_amount']} {rec.get('commitment_type', 'UCM')} commitment"
|
||||
if summary:
|
||||
gen.add_recommendation_slide(
|
||||
summary=summary,
|
||||
next_steps=rec.get("next_steps", []),
|
||||
)
|
||||
|
||||
# Closing slides
|
||||
prepared_by = bc.get("prepared_by", "")
|
||||
gen.add_closing_slide(name=prepared_by)
|
||||
|
||||
return gen
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CLI
|
||||
# ============================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate OCI business case slide deck (.pptx)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spec", required=True,
|
||||
help="Path to YAML business case spec file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", default="business-case.pptx",
|
||||
help="Output .pptx file path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
help="Path to Oracle FY26 .pptx template (default: templates/Oracle_PPT-template_FY26.pptx)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.spec, 'r') as f:
|
||||
spec = yaml.safe_load(f)
|
||||
|
||||
gen = BusinessCaseDeckGenerator.from_spec(spec, template=args.template)
|
||||
gen.save(args.output)
|
||||
|
||||
print(f"Generated: {args.output}")
|
||||
print(f" Slides: {gen.slide_count}")
|
||||
print(f" Template: Oracle FY26")
|
||||
customer = spec.get("business_case", spec).get("customer_name", "")
|
||||
if customer:
|
||||
print(f" Customer: {customer}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user