Files
oci-deal-accelerator/tools/oci_bizcase_gen.py
root fc550c14bb Drop closing blanks, honor primary_driver, catalog data-services SKUs
- oci_deck_gen / oci_bizcase_gen: stop appending the Thank You + Oracle
  logo-only closing slides at the end of every generated deck.
- oci_bizcase_gen: Business Drivers now reads primary_driver at the
  drivers-level and at the top level, and renders natural-language values
  verbatim (only snake_case enum tokens are Title-cased). A spec-provided
  drivers.items / additional / secondary list replaces the hardcoded
  "Financial Impact of Inaction" + "Operational Impact" fallback cards.
- oci-sku-catalog.yaml: add data_services category with the four real
  Big Data Service SKUs (B91128/B91129/B91130/B93555) plus EST-DS-NOTEBOOK,
  EST-DS-MODEL, EST-DF-SPARK as estimate placeholders (OCI Data Science /
  Data Flow have no dedicated SKUs in the public pricing API — they are
  billed via the underlying compute shape).
- oci_bom_gen: resolver now prints a stderr warning and tags estimate/
  unknown line items with an explicit note; the xlsx writer renders
  "other" category items under an "Uncategorized — confirm SKUs" section
  at the end instead of silently dropping them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:34:37 -03:00

777 lines
33 KiB
Python

#!/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
# ============================================================
# Defensive input helpers
# ============================================================
def pick(mapping: dict, *keys, default=""):
"""Return the first non-empty value for any of the provided keys."""
if not isinstance(mapping, dict):
return default
for key in keys:
if key in mapping:
value = mapping.get(key)
if value is not None:
return value
return default
def pick_list(mapping: dict, *keys):
"""Return the first list-like field normalized to a list."""
if not isinstance(mapping, dict):
return []
for key in keys:
if key in mapping:
value = mapping.get(key)
if value is None:
return []
if isinstance(value, list):
return value
return [value]
return []
# ============================================================
# 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 = pick(phase, "name", "title", default=f"Phase {i+1}")
duration = pick(phase, "duration", "weeks")
deliverables = pick_list(phase, "deliverables", "outputs")
quick_wins = pick_list(phase, "quick_wins", "milestones")
# 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=pick(bc, "customer_name", "customer"),
subtitle="Business Case for Oracle Cloud Infrastructure",
prepared_by=pick(bc, "prepared_by", "author"),
date=pick(bc, "date", "generated_on"),
)
# 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", {})
# Accept primary_driver at the top level as well (common user shape)
top_primary = pick(bc, "primary_driver", "main_driver")
# Accept a plain string or list for drivers
if isinstance(drivers_data, str):
drivers_data = {"primary": drivers_data}
elif isinstance(drivers_data, list):
drivers_data = {"items": drivers_data}
elif not isinstance(drivers_data, dict):
drivers_data = {}
if drivers_data or top_primary:
driver_statements = []
primary = pick(drivers_data, "primary", "primary_driver", "main_driver") or top_primary
urgency = pick(drivers_data, "urgency", "why_now")
coi = drivers_data.get("cost_of_inaction", {}) or {}
if primary:
primary_text = primary if isinstance(primary, str) else str(primary)
# Only rewrite snake_case enum tokens ("compliance_driven"); leave natural
# language intact so values like "Soberanía de datos + compliance regulatorio"
# render verbatim.
if "_" in primary_text and " " not in primary_text and len(primary_text) <= 40:
headline = f"Primary Driver: {primary_text.replace('_', ' ').title()}"
else:
headline = primary_text
driver_statements.append(f"{headline}\n{urgency}" if urgency else headline)
# Spec-provided additional cards take precedence over the inaction fallback
extra_items = pick_list(drivers_data, "items", "additional", "secondary", "cards")
for item in extra_items:
if isinstance(item, str) and item.strip():
driver_statements.append(item.strip())
elif isinstance(item, dict):
label = pick(item, "label", "headline", "title", "name")
desc = pick(item, "detail", "description", "text", "body")
if label and desc:
driver_statements.append(f"{label}\n{desc}")
elif label or desc:
driver_statements.append(label or desc)
if not extra_items:
financial = pick(coi, "financial")
operational = pick(coi, "operational")
strategic = pick(coi, "strategic")
if financial:
driver_statements.append(f"Financial Impact of Inaction\n{financial}")
if operational:
driver_statements.append(f"Operational Impact\n{operational}")
elif strategic:
driver_statements.append(f"Strategic Risk\n{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 = pick_list(risks, "migration_risks")
do_nothing_risks = pick_list(risks, "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=pick_list(roadmap, "phases"),
total_duration=pick(roadmap, "total_duration"),
)
# Slide 9: Recommendation
rec = bc.get("recommendation", {})
if rec:
summary = pick(rec, "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=pick_list(rec, "next_steps"),
)
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()