Enhance ADB-S to ADB-D business cases
Some checks failed
Deploy Skill to OCI / deploy (push) Failing after 19s
Diagram validators / diagram-validators (push) Failing after 22s
SKILL.md sync / check-sync (push) Successful in 17s
SKU Catalog Refresh / refresh-and-discover (push) Failing after 30s
KB Health Check / check-links (push) Successful in 57s
Some checks failed
Deploy Skill to OCI / deploy (push) Failing after 19s
Diagram validators / diagram-validators (push) Failing after 22s
SKILL.md sync / check-sync (push) Successful in 17s
SKU Catalog Refresh / refresh-and-discover (push) Failing after 30s
KB Health Check / check-links (push) Successful in 57s
This commit is contained in:
@@ -24,12 +24,24 @@ 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 pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
|
||||
from pptx.chart.data import ChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
|
||||
|
||||
try:
|
||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
from oci_business_case_model import (
|
||||
enrich_adbs_to_adbd_business_case,
|
||||
write_bom_outputs,
|
||||
)
|
||||
from pptx_validator import validate_pptx
|
||||
except ModuleNotFoundError:
|
||||
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
from tools.oci_business_case_model import (
|
||||
enrich_adbs_to_adbd_business_case,
|
||||
write_bom_outputs,
|
||||
)
|
||||
from tools.pptx_validator import validate_pptx
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -256,7 +268,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
# ================================================================
|
||||
|
||||
def add_cover_slide(self, customer: str, subtitle: str = "",
|
||||
prepared_by: str = "", date: str = ""):
|
||||
prepared_by: str = "", date: str = "",
|
||||
prepared_by_role: str = ""):
|
||||
"""Slide 1: Cover using Dark Title_Pillar layout."""
|
||||
slide = self._add_layout_slide(Layouts.COVER_DARK)
|
||||
self._set_placeholder(slide, 0, customer) # Title
|
||||
@@ -264,7 +277,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
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}")
|
||||
byline = f"{prepared_by}, {prepared_by_role}" if prepared_by_role else prepared_by
|
||||
self._set_placeholder(slide, 34, f"Prepared by: {byline}")
|
||||
|
||||
def add_executive_summary_slide(self, statement: str):
|
||||
"""Slide 2: Executive Summary — controlled typography on blank slide."""
|
||||
@@ -346,7 +360,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
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",
|
||||
text=tco.get("comparison_label", f"{horizon}-Year Comparison | Current State vs Oracle Cloud Infrastructure"),
|
||||
font_size=13, color=Colors.TEAL, bold=True,
|
||||
)
|
||||
|
||||
@@ -354,8 +368,34 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
proposed = tco.get("proposed_oci", {})
|
||||
savings = tco.get("savings", {})
|
||||
|
||||
ann_current = current.get("total_annual", 0)
|
||||
ann_oci = proposed.get("total_annual", 0)
|
||||
ann_savings = savings.get("annual", 0) or (ann_current - ann_oci)
|
||||
monthly_current = ann_current / 12 if ann_current else 0
|
||||
monthly_oci = ann_oci / 12 if ann_oci else 0
|
||||
|
||||
kpi_labels = tco.get("kpi_labels", {}) if isinstance(tco.get("kpi_labels", {}), dict) else {}
|
||||
kpis = [
|
||||
(f"${monthly_current:,.0f}/mo", kpi_labels.get("current", "Current state")),
|
||||
(f"${monthly_oci:,.0f}/mo", kpi_labels.get("proposed", "Proposed OCI")),
|
||||
(f"${abs(ann_savings) / 12:,.0f}/mo", kpi_labels.get("delta", "Monthly delta")),
|
||||
]
|
||||
for idx, (value, label) in enumerate(kpis):
|
||||
x = Inches(0.8 + idx * 4.0)
|
||||
box = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, Inches(1.55), Inches(3.65), Inches(1.0))
|
||||
box.fill.solid()
|
||||
box.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
box.line.color.rgb = Colors.MUTED_TEAL
|
||||
self._add_textbox(slide, x + Inches(0.15), Inches(1.68), Inches(3.35), Inches(0.4),
|
||||
text=value, font_size=20, bold=True, color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
self._add_textbox(slide, x + Inches(0.15), Inches(2.08), Inches(3.35), Inches(0.28),
|
||||
text=label, font_size=10, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
|
||||
row_labels = tco.get("row_labels", {}) if isinstance(tco.get("row_labels", {}), dict) else {}
|
||||
rows_data = [
|
||||
("Infrastructure", current.get("annual_infrastructure", 0), proposed.get("annual_cloud_consumption", 0)),
|
||||
(row_labels.get("infrastructure", "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)),
|
||||
@@ -368,9 +408,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
migration = proposed.get("migration_one_time", 0)
|
||||
|
||||
num_rows = len(rows_data) + 3 # header + rows + annual total + horizon total
|
||||
table_top = Inches(3.0)
|
||||
table = self._add_table(
|
||||
slide, num_rows, 4,
|
||||
Inches(0.8), Inches(1.6),
|
||||
Inches(0.8), table_top,
|
||||
Inches(11.7), Inches(0.42 * num_rows),
|
||||
)
|
||||
table.columns[0].width = Inches(3.5)
|
||||
@@ -379,7 +420,14 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
table.columns[3].width = Inches(2.8)
|
||||
|
||||
# Header
|
||||
for j, h in enumerate(["Cost Category", "Current (Annual)", "OCI (Annual)", "Savings"]):
|
||||
column_labels = tco.get("column_labels", {}) if isinstance(tco.get("column_labels", {}), dict) else {}
|
||||
headers = [
|
||||
"Cost Category",
|
||||
column_labels.get("current", "Current (Annual)"),
|
||||
column_labels.get("proposed", "OCI (Annual)"),
|
||||
column_labels.get("delta", "Savings"),
|
||||
]
|
||||
for j, h in enumerate(headers):
|
||||
self._style_table_cell(
|
||||
table.cell(0, j), h, font_size=11, bold=True,
|
||||
color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||
@@ -401,9 +449,9 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
# 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)
|
||||
ann_current = ann_current or sum(r[1] for r in rows_data if isinstance(r[1], (int, float)))
|
||||
ann_oci = ann_oci or sum(r[2] for r in rows_data if isinstance(r[2], (int, float)))
|
||||
ann_savings = ann_savings 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)
|
||||
@@ -422,7 +470,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
# Migration note
|
||||
if migration:
|
||||
note_y = Inches(1.6) + Inches(0.42 * num_rows) + Inches(0.15)
|
||||
note_y = table_top + 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",
|
||||
@@ -432,16 +480,16 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
# Assumptions
|
||||
assumptions = tco.get("assumptions", [])
|
||||
if assumptions:
|
||||
a_y = Inches(1.6) + Inches(0.42 * num_rows) + Inches(0.45)
|
||||
a_y = table_top + 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]):
|
||||
for idx, a in enumerate(assumptions[:7]):
|
||||
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,
|
||||
text=f"• {a}", font_size=7, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_roi_slide(self, roi: dict, headline: str = ""):
|
||||
@@ -450,6 +498,56 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
self._add_title_bar(slide, "Return on Investment")
|
||||
|
||||
cards = roi.get("cards", [])
|
||||
if cards:
|
||||
metric_text = headline or pick(roi, "headline", default="ROI logic")
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(1.15), Inches(11.7), Inches(0.62),
|
||||
text=metric_text, font_size=34, bold=True, color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
summary = pick(roi, "label", "summary")
|
||||
if summary:
|
||||
self._add_textbox(
|
||||
slide, Inches(1.0), Inches(1.82), Inches(11.3), Inches(0.5),
|
||||
text=summary, font_size=13, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
card_w = Inches(3.65)
|
||||
card_h = Inches(2.25)
|
||||
gap = Inches(0.35)
|
||||
x0 = Inches(0.9)
|
||||
y0 = Inches(2.75)
|
||||
colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST]
|
||||
for idx, card in enumerate(cards[:3]):
|
||||
x = x0 + idx * (card_w + gap)
|
||||
bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y0, card_w, card_h)
|
||||
bg.fill.solid()
|
||||
bg.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
bg.line.color.rgb = colors[idx % len(colors)]
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.18), y0 + Inches(0.18), card_w - Inches(0.36), Inches(0.28),
|
||||
text=card.get("title", ""), font_size=11, bold=True, color=colors[idx % len(colors)],
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.18), y0 + Inches(0.58), card_w - Inches(0.36), Inches(0.48),
|
||||
text=card.get("metric", ""), font_size=18, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.18), y0 + Inches(1.14), card_w - Inches(0.36), Inches(0.88),
|
||||
text=card.get("detail", ""), font_size=8, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
note = pick(roi, "note")
|
||||
if note:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.9), Inches(5.45), Inches(11.5), Inches(0.65),
|
||||
text=note, font_size=10, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
return
|
||||
|
||||
# Big centered ROI number
|
||||
pct = roi.get("three_year_roi_pct", 0)
|
||||
metric_text = headline or (f"{pct:.0f}%" if pct else "—")
|
||||
@@ -466,7 +564,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
# 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,
|
||||
text=roi.get("label", "3-Year Return on Investment"), font_size=16,
|
||||
color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
@@ -509,11 +607,349 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
def add_tco_projection_slide(
|
||||
self,
|
||||
projection: list,
|
||||
title: str = "3-Year TCO Projection",
|
||||
storage_economics: dict | None = None,
|
||||
):
|
||||
"""Slide: multi-year TCO forecast with CPU and storage assumptions."""
|
||||
if not projection:
|
||||
return
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, title)
|
||||
|
||||
headers = ["Period", "CPU Demand", "Storage", "ADB-S As-Is", "ADB-D To-Be", "Delta", "Note"]
|
||||
rows = projection[:4]
|
||||
table = self._add_table(
|
||||
slide, len(rows) + 1, len(headers),
|
||||
Inches(0.55), Inches(1.35),
|
||||
Inches(12.25), Inches(0.55 * (len(rows) + 1)),
|
||||
)
|
||||
widths = [1.05, 1.65, 1.8, 1.65, 1.65, 1.35, 3.1]
|
||||
for idx, width in enumerate(widths):
|
||||
table.columns[idx].width = Inches(width)
|
||||
|
||||
for j, header in enumerate(headers):
|
||||
self._style_table_cell(
|
||||
table.cell(0, j), header, font_size=9, bold=True,
|
||||
color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER if j else PP_ALIGN.LEFT,
|
||||
)
|
||||
|
||||
for i, row in enumerate(rows, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if i % 2 == 0 else None
|
||||
values = [
|
||||
row.get("period", ""),
|
||||
row.get("cpu", ""),
|
||||
row.get("storage", ""),
|
||||
row.get("as_is", ""),
|
||||
row.get("to_be", ""),
|
||||
row.get("delta", ""),
|
||||
row.get("note", ""),
|
||||
]
|
||||
for j, value in enumerate(values):
|
||||
self._style_table_cell(
|
||||
table.cell(i, j), value, font_size=8, bg_color=bg,
|
||||
alignment=PP_ALIGN.RIGHT if j in (3, 4, 5) else PP_ALIGN.LEFT,
|
||||
color=Colors.ERROR if j == 5 and str(value).startswith("+") else Colors.PRIMARY_TEXT,
|
||||
)
|
||||
|
||||
footnote = rows[0].get("footnote", "") if isinstance(rows[0], dict) else ""
|
||||
if footnote:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.65), Inches(6.25), Inches(12.0), Inches(0.45),
|
||||
text=footnote, font_size=8, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
if isinstance(storage_economics, dict) and storage_economics:
|
||||
y = Inches(4.35)
|
||||
box = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(0.75), y, Inches(11.85), Inches(1.55))
|
||||
box.fill.solid()
|
||||
box.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
box.line.color.rgb = Colors.MUTED_TEAL
|
||||
|
||||
self._add_textbox(
|
||||
slide, Inches(0.95), y + Inches(0.12), Inches(11.45), Inches(0.26),
|
||||
text=pick(storage_economics, "headline", default="Storage economics"),
|
||||
font_size=10, bold=True, color=Colors.TEAL,
|
||||
)
|
||||
|
||||
cards = storage_economics.get("cards", [])
|
||||
if cards:
|
||||
card_w = Inches(3.55)
|
||||
for idx, card in enumerate(cards[:3]):
|
||||
x = Inches(0.95) + idx * Inches(3.8)
|
||||
self._add_textbox(
|
||||
slide, x, y + Inches(0.45), card_w, Inches(0.35),
|
||||
text=card.get("value", ""), font_size=15, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, x, y + Inches(0.82), card_w, Inches(0.34),
|
||||
text=card.get("label", ""), font_size=8, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
detail = card.get("detail", "")
|
||||
if detail:
|
||||
self._add_textbox(
|
||||
slide, x, y + Inches(1.13), card_w, Inches(0.28),
|
||||
text=detail, font_size=7, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_tco_crossover_slide(self, chart_spec: dict):
|
||||
"""Slide: show when ADB-D becomes cheaper than ADB-S."""
|
||||
if not isinstance(chart_spec, dict):
|
||||
return
|
||||
|
||||
categories = chart_spec.get("categories", [])
|
||||
as_is = chart_spec.get("as_is", [])
|
||||
to_be = chart_spec.get("to_be", [])
|
||||
if not categories or not as_is or not to_be:
|
||||
return
|
||||
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, pick(chart_spec, "title", default="TCO Crossover"))
|
||||
|
||||
subtitle = pick(chart_spec, "subtitle")
|
||||
if subtitle:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.75), Inches(1.05), Inches(12.0), Inches(0.35),
|
||||
text=subtitle, font_size=13, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
# Draw the chart with native shapes so it remains visible in lightweight
|
||||
# renderers that do not paint embedded Office chart objects.
|
||||
y_min = float(chart_spec.get("y_axis_min", min(as_is + to_be) * 0.9))
|
||||
y_max = float(chart_spec.get("y_axis_max", max(as_is + to_be) * 1.1))
|
||||
y_step = float(chart_spec.get("y_axis_major_unit", 0.5))
|
||||
plot_x, plot_y = Inches(0.95), Inches(1.8)
|
||||
plot_w, plot_h = Inches(7.75), Inches(4.2)
|
||||
axis_color = Colors.SECONDARY_TEXT
|
||||
grid_color = RGBColor(220, 218, 214)
|
||||
series_colors = [Colors.BURNT_ORANGE, Colors.TEAL]
|
||||
|
||||
def x_pos(idx: int) -> int:
|
||||
if len(categories) == 1:
|
||||
return int(plot_x + plot_w / 2)
|
||||
return int(plot_x + idx * (plot_w / (len(categories) - 1)))
|
||||
|
||||
def y_pos(value: float) -> int:
|
||||
ratio = (float(value) - y_min) / (y_max - y_min)
|
||||
return int(plot_y + plot_h - ratio * plot_h)
|
||||
|
||||
# Grid and y labels.
|
||||
tick = y_min
|
||||
while tick <= y_max + 0.001:
|
||||
y = y_pos(tick)
|
||||
grid = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, plot_x, y, plot_x + plot_w, y)
|
||||
grid.line.color.rgb = grid_color
|
||||
grid.line.width = Pt(0.6)
|
||||
self._add_textbox(
|
||||
slide, Inches(0.52), y - Inches(0.1), Inches(0.35), Inches(0.2),
|
||||
text=f"${tick:.1f}M", font_size=7, color=Colors.SECONDARY_TEXT,
|
||||
alignment=PP_ALIGN.RIGHT,
|
||||
)
|
||||
tick += y_step
|
||||
|
||||
# Axes.
|
||||
x_axis = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, plot_x, plot_y + plot_h, plot_x + plot_w, plot_y + plot_h)
|
||||
x_axis.line.color.rgb = axis_color
|
||||
y_axis = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, plot_x, plot_y, plot_x, plot_y + plot_h)
|
||||
y_axis.line.color.rgb = axis_color
|
||||
|
||||
baseline_y = y_pos(y_min)
|
||||
group_w = plot_w / len(categories)
|
||||
bar_w = min(Inches(0.28), group_w * 0.28)
|
||||
gap_w = Inches(0.06)
|
||||
|
||||
for idx, category in enumerate(categories):
|
||||
center = int(plot_x + group_w * (idx + 0.5))
|
||||
values = [as_is[idx], to_be[idx]]
|
||||
for s_idx, value in enumerate(values):
|
||||
color = series_colors[s_idx]
|
||||
x = center - bar_w - gap_w / 2 if s_idx == 0 else center + gap_w / 2
|
||||
y = y_pos(value)
|
||||
h = baseline_y - y
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, int(x), int(y), int(bar_w), int(h))
|
||||
bar.fill.solid()
|
||||
bar.fill.fore_color.rgb = color
|
||||
bar.line.fill.background()
|
||||
self._add_textbox(
|
||||
slide, int(x) - Inches(0.16), y - Inches(0.26), bar_w + Inches(0.32), Inches(0.2),
|
||||
text=f"${value:.2f}M", font_size=7, color=color,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
for idx, category in enumerate(categories):
|
||||
center = int(plot_x + group_w * (idx + 0.5))
|
||||
self._add_textbox(
|
||||
slide, center - Inches(0.45), plot_y + plot_h + Inches(0.1),
|
||||
Inches(0.9), Inches(0.25), text=str(category), font_size=8,
|
||||
color=Colors.SECONDARY_TEXT, alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
# Legend.
|
||||
legend_y = plot_y + plot_h + Inches(0.55)
|
||||
for idx, (label, color) in enumerate([
|
||||
(pick(chart_spec, "as_is_label", default="ADB-S"), series_colors[0]),
|
||||
(pick(chart_spec, "to_be_label", default="ADB-D"), series_colors[1]),
|
||||
]):
|
||||
x = plot_x + Inches(idx * 2.2)
|
||||
swatch = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, legend_y, Inches(0.18), Inches(0.08))
|
||||
swatch.fill.solid()
|
||||
swatch.fill.fore_color.rgb = color
|
||||
swatch.line.fill.background()
|
||||
self._add_textbox(slide, x + Inches(0.25), legend_y - Inches(0.06), Inches(1.6), Inches(0.22), text=label, font_size=8, color=Colors.SECONDARY_TEXT)
|
||||
|
||||
callout = pick(chart_spec, "callout", default="")
|
||||
if callout:
|
||||
box = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
Inches(9.15), Inches(1.7), Inches(3.35), Inches(1.25),
|
||||
)
|
||||
box.fill.solid()
|
||||
box.fill.fore_color.rgb = Colors.TEAL
|
||||
box.line.fill.background()
|
||||
self._add_textbox(
|
||||
slide, Inches(9.35), Inches(1.92), Inches(2.95), Inches(0.78),
|
||||
text=callout, font_size=17, bold=True, color=Colors.WHITE,
|
||||
alignment=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
bullets = chart_spec.get("bullets", [])
|
||||
y = Inches(3.25)
|
||||
for idx, bullet in enumerate(bullets[:4]):
|
||||
self._add_textbox(
|
||||
slide, Inches(9.2), y + idx * Inches(0.55), Inches(3.25), Inches(0.42),
|
||||
text=f"- {bullet}", font_size=9, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
note = pick(chart_spec, "note")
|
||||
if note:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.85), Inches(6.35), Inches(11.8), Inches(0.35),
|
||||
text=note, font_size=8, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_cost_breakdown_slide(self, breakdown: dict):
|
||||
"""Slide: explain how cloud services and operations costs are built."""
|
||||
if not isinstance(breakdown, dict):
|
||||
return
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, pick(breakdown, "title", default="BOM + Operations Cost Breakdown"))
|
||||
|
||||
scenarios = breakdown.get("scenarios", [])
|
||||
if not scenarios:
|
||||
return
|
||||
|
||||
col_w = Inches(5.8)
|
||||
gap = Inches(0.55)
|
||||
x_positions = [Inches(0.75), Inches(0.75) + col_w + gap]
|
||||
colors = [Colors.TEAL, Colors.BURNT_ORANGE]
|
||||
|
||||
for idx, scenario in enumerate(scenarios[:2]):
|
||||
x = x_positions[idx]
|
||||
y = Inches(1.3)
|
||||
color = colors[idx % len(colors)]
|
||||
header = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, col_w, Inches(0.45))
|
||||
header.fill.solid()
|
||||
header.fill.fore_color.rgb = color
|
||||
header.line.fill.background()
|
||||
self._add_textbox(
|
||||
slide, x + Inches(0.15), y + Inches(0.06), col_w - Inches(0.3), Inches(0.32),
|
||||
text=pick(scenario, "name", default=f"Scenario {idx+1}"),
|
||||
font_size=12, bold=True, color=Colors.WHITE,
|
||||
)
|
||||
|
||||
rows = scenario.get("lines", [])
|
||||
table = self._add_table(
|
||||
slide, len(rows) + 2, 3,
|
||||
x, y + Inches(0.6),
|
||||
col_w, Inches(0.32 * (len(rows) + 2)),
|
||||
)
|
||||
table.columns[0].width = Inches(3.0)
|
||||
table.columns[1].width = Inches(1.35)
|
||||
table.columns[2].width = Inches(1.45)
|
||||
for j, h in enumerate(["Cost Item", "Monthly", "Annual"]):
|
||||
self._style_table_cell(
|
||||
table.cell(0, j), h, font_size=8, bold=True,
|
||||
color=Colors.WHITE, bg_color=color,
|
||||
alignment=PP_ALIGN.RIGHT if j else PP_ALIGN.LEFT,
|
||||
)
|
||||
for r_idx, row in enumerate(rows, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if r_idx % 2 == 0 else None
|
||||
self._style_table_cell(table.cell(r_idx, 0), row.get("item", ""), font_size=7, bg_color=bg)
|
||||
self._style_table_cell(table.cell(r_idx, 1), row.get("monthly", ""), font_size=7, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(r_idx, 2), row.get("annual", ""), font_size=7, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
total = scenario.get("total", {})
|
||||
last = len(rows) + 1
|
||||
self._style_table_cell(table.cell(last, 0), "TOTAL", font_size=8, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG)
|
||||
self._style_table_cell(table.cell(last, 1), total.get("monthly", ""), font_size=8, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(last, 2), total.get("annual", ""), font_size=8, bold=True, color=Colors.WHITE, bg_color=Colors.DARK_BG, alignment=PP_ALIGN.RIGHT)
|
||||
|
||||
notes = breakdown.get("notes", [])
|
||||
if notes:
|
||||
y = Inches(6.0)
|
||||
self._add_textbox(slide, Inches(0.8), y, Inches(11.8), Inches(0.25), text="Assumptions:", font_size=8, bold=True, color=Colors.SECONDARY_TEXT)
|
||||
for idx, note in enumerate(notes[:4]):
|
||||
self._add_textbox(
|
||||
slide, Inches(1.0), y + Inches(0.23 + idx * 0.2), Inches(11.6), Inches(0.2),
|
||||
text=f"- {note}", font_size=7, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
def add_business_value_slide(self, value_case: dict):
|
||||
"""Slide: risk-adjusted value / avoided downtime model."""
|
||||
if not isinstance(value_case, dict):
|
||||
return
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, pick(value_case, "title", default="Risk-Adjusted Business Value"))
|
||||
|
||||
headline = pick(value_case, "headline")
|
||||
if headline:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(1.15), Inches(11.8), Inches(0.5),
|
||||
text=headline, font_size=16, bold=True, color=Colors.TEAL,
|
||||
)
|
||||
|
||||
rows = value_case.get("rows", [])
|
||||
if rows:
|
||||
headers = ["Value Area", "How to Measure", "Business Case Treatment"]
|
||||
table = self._add_table(slide, len(rows) + 1, 3, Inches(0.75), Inches(1.85), Inches(12.0), Inches(0.75 * (len(rows) + 1)))
|
||||
table.columns[0].width = Inches(2.6)
|
||||
table.columns[1].width = Inches(4.3)
|
||||
table.columns[2].width = Inches(5.1)
|
||||
for j, h in enumerate(headers):
|
||||
self._style_table_cell(table.cell(0, j), h, font_size=9, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL)
|
||||
for i, row in enumerate(rows, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if i % 2 == 0 else None
|
||||
self._style_table_cell(table.cell(i, 0), row.get("area", ""), font_size=8, bg_color=bg)
|
||||
self._style_table_cell(table.cell(i, 1), row.get("measure", ""), font_size=8, bg_color=bg)
|
||||
self._style_table_cell(table.cell(i, 2), row.get("treatment", ""), font_size=8, bg_color=bg)
|
||||
|
||||
break_even = value_case.get("break_even", [])
|
||||
if break_even:
|
||||
self._add_textbox(
|
||||
slide, Inches(0.8), Inches(5.25), Inches(11.8), Inches(0.3),
|
||||
text="Break-even avoided outage impact", font_size=10, bold=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
headers = ["Horizon", "Incremental TCO", "If avoiding 4h/year", "If avoiding 8h/year"]
|
||||
table = self._add_table(slide, len(break_even) + 1, 4, Inches(0.8), Inches(5.6), Inches(11.7), Inches(0.38 * (len(break_even) + 1)))
|
||||
for j, h in enumerate(headers):
|
||||
self._style_table_cell(table.cell(0, j), h, font_size=8, bold=True, color=Colors.WHITE, bg_color=Colors.BURNT_ORANGE)
|
||||
for i, row in enumerate(break_even, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if i % 2 == 0 else None
|
||||
values = [row.get("horizon", ""), row.get("investment", ""), row.get("four_hours", ""), row.get("eight_hours", "")]
|
||||
for j, value in enumerate(values):
|
||||
self._style_table_cell(table.cell(i, j), value, font_size=8, bg_color=bg, alignment=PP_ALIGN.RIGHT if j else PP_ALIGN.LEFT)
|
||||
|
||||
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}
|
||||
"""
|
||||
drivers = [
|
||||
{"title": driver, "description": ""} if isinstance(driver, str) else driver
|
||||
for driver in drivers
|
||||
]
|
||||
slide = self._add_blank_slide()
|
||||
|
||||
self._add_title_bar(slide, "Value Drivers")
|
||||
@@ -587,8 +1023,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
# Column header backgrounds
|
||||
for x, color, label in [
|
||||
(left_x, Colors.TEAL, "✓ Migration Risks (Mitigated)"),
|
||||
(right_x, Colors.ERROR, "⚠ Risks of Inaction"),
|
||||
(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()
|
||||
@@ -614,7 +1050,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
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,
|
||||
text=f"- {r_text}", font_size=12, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
if mitigation:
|
||||
self._add_textbox(
|
||||
@@ -638,7 +1074,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
|
||||
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,
|
||||
text=f"- {r_text}", font_size=12, bold=True, color=Colors.ERROR,
|
||||
)
|
||||
detail_parts = []
|
||||
if impact:
|
||||
@@ -794,6 +1230,21 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
)
|
||||
y += Inches(0.6)
|
||||
|
||||
def add_disclaimer_slide(self, disclaimer: str):
|
||||
"""Final slide: commercial disclaimer for price-bearing business cases."""
|
||||
if not disclaimer:
|
||||
return
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Commercial Disclaimer")
|
||||
body = slide.shapes.add_textbox(Inches(0.8), Inches(1.25), Inches(11.7), Inches(4.9))
|
||||
tf = body.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.text = disclaimer
|
||||
p.font.size = Pt(12)
|
||||
p.font.name = self.FONT
|
||||
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||
|
||||
# ================================================================
|
||||
# Build from YAML spec
|
||||
# ================================================================
|
||||
@@ -801,6 +1252,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
@classmethod
|
||||
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "BusinessCaseDeckGenerator":
|
||||
"""Build a complete business case deck from a YAML specification."""
|
||||
spec = enrich_adbs_to_adbd_business_case(spec)
|
||||
spec = _enrich_sparse_business_case(spec)
|
||||
bc = spec.get("business_case", spec) # Support both wrapped and unwrapped
|
||||
gen = cls(template=template)
|
||||
@@ -808,8 +1260,14 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
# Slide 1: Cover
|
||||
gen.add_cover_slide(
|
||||
customer=pick(bc, "customer_name", "customer"),
|
||||
subtitle="Business Case for Oracle Cloud Infrastructure",
|
||||
subtitle=pick(
|
||||
bc,
|
||||
"cover_subtitle",
|
||||
"subtitle",
|
||||
default="ADB-S to ADB-D Migration Business Case",
|
||||
),
|
||||
prepared_by=pick(bc, "prepared_by", "author"),
|
||||
prepared_by_role=pick(bc, "prepared_by_role", "role", "title"),
|
||||
date=pick(bc, "date", "generated_on"),
|
||||
)
|
||||
|
||||
@@ -879,10 +1337,30 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
if tco and (tco.get("current_state") or tco.get("proposed_oci")):
|
||||
gen.add_tco_slide(tco)
|
||||
|
||||
breakdown = tco.get("breakdown", {})
|
||||
if breakdown:
|
||||
gen.add_cost_breakdown_slide(breakdown)
|
||||
|
||||
projection = pick_list(tco, "projection", "forecast")
|
||||
if projection:
|
||||
gen.add_tco_projection_slide(
|
||||
projection,
|
||||
title=pick(tco, "projection_title", default="3-Year TCO Projection"),
|
||||
storage_economics=tco.get("storage_economics"),
|
||||
)
|
||||
|
||||
crossover_chart = tco.get("crossover_chart", {})
|
||||
if crossover_chart:
|
||||
gen.add_tco_crossover_slide(crossover_chart)
|
||||
|
||||
value_case = tco.get("business_value", {})
|
||||
if value_case:
|
||||
gen.add_business_value_slide(value_case)
|
||||
|
||||
# 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)
|
||||
if roi and (roi.get("cards") or any(roi.get(k) for k in ["three_year_roi_pct", "payback_months", "total_investment"])):
|
||||
gen.add_roi_slide(roi, headline=pick(roi, "headline"))
|
||||
|
||||
# Slide 6: Value Drivers
|
||||
value_drivers = bc.get("value_drivers", [])
|
||||
@@ -916,6 +1394,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
||||
next_steps=pick_list(rec, "next_steps"),
|
||||
)
|
||||
|
||||
disclaimer = pick(bc, "commercial_disclaimer", "disclaimer")
|
||||
if disclaimer:
|
||||
gen.add_disclaimer_slide(disclaimer)
|
||||
|
||||
return gen
|
||||
|
||||
|
||||
@@ -939,11 +1421,30 @@ def main():
|
||||
"--template",
|
||||
help="Path to Oracle FY26 .pptx template (default: templates/Oracle_PPT-template_FY26.pptx)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-boms",
|
||||
action="store_true",
|
||||
help="Do not generate companion BOM YAML/XLSX files for ADB-S to ADB-D cases.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-validate",
|
||||
action="store_true",
|
||||
help="Skip structural PPTX validation.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.spec, 'r') as f:
|
||||
spec = yaml.safe_load(f)
|
||||
|
||||
bc = spec.get("business_case", spec)
|
||||
if not pick(bc, "commercial_disclaimer", "disclaimer"):
|
||||
catalog_path = Path(__file__).resolve().parent.parent / "kb" / "pricing" / "oci-sku-catalog.yaml"
|
||||
if catalog_path.exists():
|
||||
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) or {}
|
||||
disclaimer = catalog.get("disclaimer", "")
|
||||
if disclaimer:
|
||||
bc["commercial_disclaimer"] = disclaimer
|
||||
|
||||
gen = BusinessCaseDeckGenerator.from_spec(spec, template=args.template)
|
||||
gen.save(args.output)
|
||||
|
||||
@@ -954,6 +1455,53 @@ def main():
|
||||
if customer:
|
||||
print(f" Customer: {customer}")
|
||||
|
||||
adbd_config = bc.get("adbs_to_adbd") or bc.get("adb_s_to_adb_d")
|
||||
if isinstance(adbd_config, dict) and not args.no_boms:
|
||||
bom_dir = Path(args.output).with_suffix("").parent / f"{Path(args.output).stem}-boms"
|
||||
adbd_config = {**adbd_config, "customer_name": pick(bc, "customer_name", "customer"), "prepared_by": pick(bc, "prepared_by", "author")}
|
||||
saved = write_bom_outputs(adbd_config, bom_dir)
|
||||
print(f" BOM outputs: {len(saved)} files in {bom_dir}")
|
||||
|
||||
if not args.no_validate:
|
||||
validation_cfg = bc.get("deck_validation", {}) if isinstance(bc.get("deck_validation"), dict) else {}
|
||||
required = validation_cfg.get("required_phrases")
|
||||
forbidden = validation_cfg.get("forbidden_phrases")
|
||||
expected_titles = validation_cfg.get("expected_titles")
|
||||
if required is None and isinstance(adbd_config, dict):
|
||||
required = [
|
||||
"BYOL/PAYG model",
|
||||
"Discount",
|
||||
"GoldenGate bridge duration",
|
||||
"Workload ECPU demand",
|
||||
"ECPU capacity",
|
||||
"Storage break-even",
|
||||
"Crossover",
|
||||
]
|
||||
if forbidden is None:
|
||||
forbidden = ["OCI Annual", "FTE-year"]
|
||||
if expected_titles is None and isinstance(adbd_config, dict):
|
||||
crossover_title = pick(adbd_config.get("crossover_chart") or {}, "title", default="TCO Crossover")
|
||||
expected_titles = [
|
||||
"Total Cost of Ownership",
|
||||
"BOM + Operations Cost Breakdown",
|
||||
crossover_title,
|
||||
"Business Value Model",
|
||||
"Commercial Disclaimer",
|
||||
]
|
||||
report = validate_pptx(
|
||||
args.output,
|
||||
expected_titles=expected_titles or [],
|
||||
required_phrases=required or [],
|
||||
forbidden_phrases=forbidden or [],
|
||||
min_slides=validation_cfg.get("min_slides"),
|
||||
max_slides=validation_cfg.get("max_slides"),
|
||||
exact_slides=validation_cfg.get("exact_slides"),
|
||||
disclaimer_last=bool(bc.get("commercial_disclaimer")),
|
||||
)
|
||||
print(f" Deck validation: {report['status']} ({report['slide_count']} slides)")
|
||||
for issue in report["issues"]:
|
||||
print(f" - {issue['code']}: {issue['message']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
658
tools/oci_business_case_model.py
Normal file
658
tools/oci_business_case_model.py
Normal file
@@ -0,0 +1,658 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reusable business-case modeling helpers for OCI Deal Accelerator.
|
||||
|
||||
The functions in this module intentionally avoid customer-specific logic. They
|
||||
turn a compact ADB-S to ADB-D scenario description into audit-friendly TCO,
|
||||
BOM, storage economics, and value-model structures consumed by the deck and
|
||||
BOM generators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from oci_bom_gen import OCIBomGenerator
|
||||
except ModuleNotFoundError:
|
||||
from tools.oci_bom_gen import OCIBomGenerator
|
||||
|
||||
|
||||
HOURS_PER_MONTH = 730
|
||||
MONTHS_PER_YEAR = 12
|
||||
|
||||
SKU_ADBS_ECPU_PAYG = "B95701"
|
||||
SKU_ADBS_ECPU_BYOL = "B95703"
|
||||
SKU_ADBS_STORAGE_ATP = "B95706"
|
||||
SKU_ADBD_ECPU_PAYG = "B110631"
|
||||
SKU_ADBD_ECPU_BYOL = "B110632"
|
||||
SKU_ADBD_BASE = "B90777"
|
||||
SKU_ADBD_DB_SERVER = "B110627"
|
||||
SKU_ADBD_STORAGE_SERVER = "B110629"
|
||||
SKU_GG_PAYG = "B92992"
|
||||
SKU_GG_BYOL = "B92993"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapacityModel:
|
||||
workload_ecpu: float
|
||||
db_nodes: float
|
||||
ecpu_per_db_node: float
|
||||
|
||||
@property
|
||||
def physical_capacity_ecpu(self) -> float:
|
||||
return self.db_nodes * self.ecpu_per_db_node
|
||||
|
||||
@property
|
||||
def utilization_pct(self) -> float:
|
||||
capacity = self.physical_capacity_ecpu
|
||||
return (self.workload_ecpu / capacity * 100) if capacity else 0
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return (
|
||||
f"{self.workload_ecpu:,.0f} ECPU used over "
|
||||
f"{self.physical_capacity_ecpu:,.0f} ECPU capacity = "
|
||||
f"{self.utilization_pct:.0f}%"
|
||||
)
|
||||
|
||||
|
||||
def pct(value: Any) -> float:
|
||||
"""Normalize discounts that may be expressed as 11 or 0.11."""
|
||||
try:
|
||||
number = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return number / 100 if number > 1 else number
|
||||
|
||||
|
||||
def money(value: float) -> str:
|
||||
sign = "-" if value < 0 else ""
|
||||
return f"{sign}USD {abs(value):,.0f}"
|
||||
|
||||
|
||||
def millions(value: float) -> float:
|
||||
return round(float(value or 0) / 1_000_000, 3)
|
||||
|
||||
|
||||
def _num(mapping: dict, *keys: str, default: float = 0.0) -> float:
|
||||
for key in keys:
|
||||
if isinstance(mapping, dict) and key in mapping and mapping.get(key) is not None:
|
||||
try:
|
||||
return float(mapping.get(key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _pick(mapping: dict, *keys: str, default: Any = "") -> Any:
|
||||
if not isinstance(mapping, dict):
|
||||
return default
|
||||
for key in keys:
|
||||
if key in mapping and mapping.get(key) is not None:
|
||||
return mapping.get(key)
|
||||
return default
|
||||
|
||||
|
||||
def _sku_price(catalog: dict[str, dict], sku: str) -> float:
|
||||
return float((catalog.get(sku) or {}).get("list_price_usd", 0) or 0)
|
||||
|
||||
|
||||
def _load_catalog(catalog_path: str | None = None) -> dict[str, dict]:
|
||||
gen = OCIBomGenerator()
|
||||
gen.load_catalog(catalog_path)
|
||||
return gen.catalog
|
||||
|
||||
|
||||
def capacity_model(stage: dict, ecpu_per_db_node: float) -> CapacityModel:
|
||||
return CapacityModel(
|
||||
workload_ecpu=_num(stage, "workload_ecpu", "ecpu_demand", default=0),
|
||||
db_nodes=_num(stage, "db_nodes", "database_servers", default=0),
|
||||
ecpu_per_db_node=ecpu_per_db_node,
|
||||
)
|
||||
|
||||
|
||||
def storage_break_even_tb(
|
||||
fixed_monthly_cost: float,
|
||||
adbs_storage_usd_per_gb_month: float,
|
||||
discount_pct: float = 0,
|
||||
) -> float:
|
||||
discounted_storage = adbs_storage_usd_per_gb_month * (1 - pct(discount_pct))
|
||||
if discounted_storage <= 0:
|
||||
return 0
|
||||
return fixed_monthly_cost / discounted_storage / 1024
|
||||
|
||||
|
||||
def first_crossover_period(periods: list[dict]) -> str:
|
||||
for row in periods:
|
||||
if float(row.get("to_be_annual_tco", 0) or 0) < float(row.get("as_is_annual_tco", 0) or 0):
|
||||
return str(row.get("label") or row.get("period") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _hours_units_for_monthly_sku(sku: str) -> int:
|
||||
return 1 if sku in {SKU_ADBS_STORAGE_ATP} else HOURS_PER_MONTH
|
||||
|
||||
|
||||
def _line(sku: str, qty: float, discount: float, label: str, note: str = "", months: int = 12) -> dict:
|
||||
return {
|
||||
"sku": sku,
|
||||
"qty": round(float(qty or 0), 4),
|
||||
"hours_units": _hours_units_for_monthly_sku(sku),
|
||||
"months": months,
|
||||
"discount": pct(discount),
|
||||
"custom_label": label,
|
||||
"custom_note": note,
|
||||
}
|
||||
|
||||
|
||||
def _goldengate_line(config: dict, discount: float, include_steady_state: bool, bridge_months: int = 0) -> dict | None:
|
||||
gg = config.get("goldengate") or {}
|
||||
mode = _pick(gg, "mode", default="")
|
||||
ocpus = _num(gg, "ocpus", "quantity", default=0)
|
||||
if not ocpus:
|
||||
return None
|
||||
include = mode == "steady_state" or include_steady_state
|
||||
if mode == "migration_bridge_only":
|
||||
include = bridge_months > 0
|
||||
if mode == "migration_plus_fallback_months":
|
||||
include = bridge_months > 0
|
||||
if not include:
|
||||
return None
|
||||
sku = SKU_GG_BYOL if str(_pick(config, "license_model", default="BYOL")).upper() == "BYOL" else SKU_GG_PAYG
|
||||
months = bridge_months or 12
|
||||
note = {
|
||||
"steady_state": "GoldenGate modeled as steady-state run-rate.",
|
||||
"migration_bridge_only": f"GoldenGate migration bridge only; modeled for {months} month(s), excluded from future steady-state BOM.",
|
||||
"migration_plus_fallback_months": f"GoldenGate migration plus fallback bridge; modeled for {months} month(s).",
|
||||
}.get(mode, "GoldenGate assumption from scenario input.")
|
||||
return _line(sku, ocpus, discount, "GoldenGate", note, months=months)
|
||||
|
||||
|
||||
def _goldengate_bridge_months(config: dict) -> int:
|
||||
gg = config.get("goldengate") or {}
|
||||
return int(_num(gg, "bridge_months", "fallback_months", default=0))
|
||||
|
||||
|
||||
def goldengate_bridge_term_cost(config: dict, catalog_path: str | None = None) -> float:
|
||||
gg = config.get("goldengate") or {}
|
||||
mode = _pick(gg, "mode", default="")
|
||||
if mode not in {"migration_bridge_only", "migration_plus_fallback_months"}:
|
||||
return 0
|
||||
ocpus = _num(gg, "ocpus", "quantity", default=0)
|
||||
months = _goldengate_bridge_months(config)
|
||||
if not ocpus or not months:
|
||||
return 0
|
||||
catalog = _load_catalog(catalog_path)
|
||||
license_model = str(_pick(config, "license_model", default="BYOL")).upper()
|
||||
sku = SKU_GG_BYOL if license_model == "BYOL" else SKU_GG_PAYG
|
||||
return _sku_price(catalog, sku) * HOURS_PER_MONTH * ocpus * months * (1 - pct(_pick(config, "discount_pct", "discount", default=0)))
|
||||
|
||||
|
||||
def _adbs_lines(config: dict, stage: dict, discount: float, label: str, include_gg: bool) -> list[dict]:
|
||||
license_model = str(_pick(config, "license_model", default="BYOL")).upper()
|
||||
sku_ecpu = SKU_ADBS_ECPU_BYOL if license_model == "BYOL" else SKU_ADBS_ECPU_PAYG
|
||||
workload_ecpu = _num(stage, "workload_ecpu", "ecpu_demand", default=0)
|
||||
storage_gb = _num(stage, "storage_gb", default=0) or _num(stage, "storage_tb", default=0) * 1024
|
||||
lines = [
|
||||
_line(
|
||||
sku_ecpu,
|
||||
workload_ecpu,
|
||||
discount,
|
||||
f"{label} workload ECPU demand",
|
||||
"Workload/billable ECPU demand; not physical dedicated capacity.",
|
||||
)
|
||||
]
|
||||
if storage_gb:
|
||||
lines.append(_line(SKU_ADBS_STORAGE_ATP, storage_gb, discount, f"{label} ATP storage", "ADB-S per-GB storage."))
|
||||
for component in stage.get("components", []) or []:
|
||||
sku = component.get("sku")
|
||||
if sku:
|
||||
lines.append(_line(
|
||||
sku,
|
||||
_num(component, "qty", "quantity", default=0),
|
||||
discount,
|
||||
_pick(component, "label", default="As-is component"),
|
||||
_pick(component, "note", default="As-is architecture component."),
|
||||
months=int(_num(component, "months", default=12)),
|
||||
))
|
||||
gg_line = _goldengate_line(config, discount, include_steady_state=include_gg)
|
||||
if gg_line:
|
||||
lines.append(gg_line)
|
||||
return lines
|
||||
|
||||
|
||||
def _adbd_lines(config: dict, stage: dict, discount: float, label: str, include_gg: bool) -> list[dict]:
|
||||
license_model = str(_pick(config, "license_model", default="BYOL")).upper()
|
||||
sku_ecpu = SKU_ADBD_ECPU_BYOL if license_model == "BYOL" else SKU_ADBD_ECPU_PAYG
|
||||
workload_ecpu = _num(stage, "workload_ecpu", "ecpu_demand", default=0)
|
||||
db_nodes = _num(stage, "db_nodes", "database_servers", default=0)
|
||||
storage_servers = _num(stage, "storage_servers", default=0)
|
||||
cap = capacity_model(stage, _num(config, "ecpu_per_db_node", default=760))
|
||||
lines = [
|
||||
_line(SKU_ADBD_BASE, 1, discount, f"{label} base infrastructure", "Dedicated base hosted environment."),
|
||||
_line(SKU_ADBD_DB_SERVER, db_nodes, discount, f"{label} DB servers", cap.label),
|
||||
_line(SKU_ADBD_STORAGE_SERVER, storage_servers, discount, f"{label} storage servers", "Fixed dedicated storage/infrastructure footprint."),
|
||||
_line(sku_ecpu, workload_ecpu, discount, f"{label} workload ECPU demand", "Billable/planning ECPU demand; does not assume 100% physical utilization."),
|
||||
]
|
||||
gg_line = _goldengate_line(config, discount, include_steady_state=include_gg)
|
||||
if gg_line:
|
||||
lines.append(gg_line)
|
||||
return lines
|
||||
|
||||
|
||||
def build_bom_specs(config: dict) -> dict[str, dict]:
|
||||
"""Build current, steady-state, and projected BOM specs from a scenario."""
|
||||
discount = pct(_pick(config, "discount_pct", "discount", default=0))
|
||||
customer = _pick(config, "customer_name", "customer", default="")
|
||||
prepared_by = _pick(config, "prepared_by", "author", default="")
|
||||
license_model = _pick(config, "license_model", default="BYOL")
|
||||
current = config.get("current") or {}
|
||||
target = config.get("target") or {}
|
||||
forecasts = config.get("forecasts") or []
|
||||
gg_mode = _pick(config.get("goldengate") or {}, "mode", default="")
|
||||
include_future_gg = gg_mode == "steady_state"
|
||||
|
||||
def spec(name: str, project: str, lines: list[dict], notes: list[str]) -> dict:
|
||||
return {
|
||||
"bom": {
|
||||
"customer_name": customer,
|
||||
"project_name": project,
|
||||
"prepared_by": prepared_by,
|
||||
"currency": "USD",
|
||||
"metadata": {"discount_pct": discount},
|
||||
"line_items": [line for line in lines if float(line.get("qty") or 0) > 0],
|
||||
"notes": notes,
|
||||
}
|
||||
}
|
||||
|
||||
common_notes = [
|
||||
f"Discount: {discount:.0%} applied uniformly to discountable lines.",
|
||||
f"License model: {license_model}.",
|
||||
"Projected BOMs are annual run-rate snapshots at horizon, not cumulative multi-year totals.",
|
||||
]
|
||||
|
||||
specs = {
|
||||
"current_as_is": spec(
|
||||
"current_as_is",
|
||||
"Current As-Is BOM",
|
||||
_adbs_lines(config, current, discount, _pick(current, "label", default="ADB-S As-Is"), include_gg=True),
|
||||
common_notes + [
|
||||
f"ECPU demand: {_num(current, 'workload_ecpu', 'ecpu_demand'):,.0f}.",
|
||||
f"Storage forecast/base: {_num(current, 'storage_tb'):,.1f} TB.",
|
||||
],
|
||||
),
|
||||
"to_be_steady_state": spec(
|
||||
"to_be_steady_state",
|
||||
"To-Be Steady-State BOM",
|
||||
_adbd_lines(config, target, discount, _pick(target, "label", default="ADB-D Dedicated"), include_gg=include_future_gg),
|
||||
common_notes + [
|
||||
capacity_model(target, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
f"GoldenGate mode: {gg_mode or 'not provided'}.",
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
gg = config.get("goldengate") or {}
|
||||
gg_mode = _pick(gg, "mode", default="")
|
||||
bridge_months = _goldengate_bridge_months(config)
|
||||
if gg_mode in {"migration_bridge_only", "migration_plus_fallback_months"} and bridge_months:
|
||||
gg_line = _goldengate_line(config, discount, include_steady_state=False, bridge_months=bridge_months)
|
||||
if gg_line:
|
||||
specs["migration_bridge_year1"] = spec(
|
||||
"migration_bridge_year1",
|
||||
"Migration Bridge Year-1 BOM",
|
||||
[gg_line],
|
||||
common_notes + [
|
||||
f"GoldenGate bridge duration: {bridge_months} month(s).",
|
||||
"Migration bridge is one-time / Year-1-only and excluded from future steady-state BOMs.",
|
||||
],
|
||||
)
|
||||
|
||||
for forecast in forecasts:
|
||||
period = str(_pick(forecast, "period", "label", default="future")).lower().replace(" ", "_")
|
||||
as_is = forecast.get("as_is") or forecast
|
||||
to_be = forecast.get("to_be") or forecast
|
||||
specs[f"as_is_projected_{period}"] = spec(
|
||||
f"as_is_projected_{period}",
|
||||
f"As-Is Projected {period.upper()} BOM",
|
||||
_adbs_lines(config, as_is, discount, "ADB-S As-Is Projected", include_gg=include_future_gg),
|
||||
common_notes + [f"Projected as-is ECPU demand: {_num(as_is, 'workload_ecpu', 'ecpu_demand'):,.0f}."],
|
||||
)
|
||||
specs[f"to_be_projected_{period}"] = spec(
|
||||
f"to_be_projected_{period}",
|
||||
f"To-Be Projected {period.upper()} BOM",
|
||||
_adbd_lines(config, to_be, discount, "ADB-D Dedicated Projected", include_gg=include_future_gg),
|
||||
common_notes + [capacity_model(to_be, _num(config, "ecpu_per_db_node", default=760)).label],
|
||||
)
|
||||
|
||||
return specs
|
||||
|
||||
|
||||
def bom_monthly_total(bom_spec: dict, catalog_path: str | None = None) -> float:
|
||||
gen = OCIBomGenerator.from_spec(bom_spec, catalog_path=catalog_path)
|
||||
total = 0.0
|
||||
for item in gen.line_items:
|
||||
_, monthly_w = gen._monthly_values(item)
|
||||
total += monthly_w
|
||||
return total
|
||||
|
||||
|
||||
def build_tco_projection(config: dict, catalog_path: str | None = None) -> list[dict]:
|
||||
specs = build_bom_specs(config)
|
||||
rows: list[dict] = []
|
||||
labels = {
|
||||
"current_as_is": "Near-term",
|
||||
"to_be_steady_state": "Near-term",
|
||||
}
|
||||
near_as_is = bom_monthly_total(specs["current_as_is"], catalog_path) * 12
|
||||
near_to_be = bom_monthly_total(specs["to_be_steady_state"], catalog_path) * 12
|
||||
current = config.get("current") or {}
|
||||
target = config.get("target") or {}
|
||||
rows.append({
|
||||
"period": "near_term",
|
||||
"label": "Near-term",
|
||||
"cpu": f"{_num(current, 'workload_ecpu', 'ecpu_demand'):,.0f} ECPU",
|
||||
"storage": f"{_num(current, 'storage_tb'):,.1f} TB",
|
||||
"as_is_annual_tco": near_as_is,
|
||||
"to_be_annual_tco": near_to_be,
|
||||
"as_is": money(near_as_is),
|
||||
"to_be": money(near_to_be),
|
||||
"delta": money(near_as_is - near_to_be),
|
||||
"note": capacity_model(target, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
})
|
||||
|
||||
for idx, forecast in enumerate(config.get("forecasts") or [], start=1):
|
||||
period = str(_pick(forecast, "period", "label", default=f"year_{idx}")).lower().replace(" ", "_")
|
||||
as_key = f"as_is_projected_{period}"
|
||||
to_key = f"to_be_projected_{period}"
|
||||
if as_key not in specs or to_key not in specs:
|
||||
continue
|
||||
as_is = forecast.get("as_is") or forecast
|
||||
to_be = forecast.get("to_be") or forecast
|
||||
as_annual = bom_monthly_total(specs[as_key], catalog_path) * 12
|
||||
to_annual = bom_monthly_total(specs[to_key], catalog_path) * 12
|
||||
label = _pick(forecast, "display_label", default=f"Year {idx}")
|
||||
rows.append({
|
||||
"period": period,
|
||||
"label": label,
|
||||
"cpu": f"{_num(as_is, 'workload_ecpu', 'ecpu_demand'):,.0f} ECPU",
|
||||
"storage": f"{_num(as_is, 'storage_tb'):,.1f} TB",
|
||||
"as_is_annual_tco": as_annual,
|
||||
"to_be_annual_tco": to_annual,
|
||||
"as_is": money(as_annual),
|
||||
"to_be": money(to_annual),
|
||||
"delta": money(as_annual - to_annual),
|
||||
"note": capacity_model(to_be, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def build_storage_economics(config: dict, catalog_path: str | None = None) -> dict:
|
||||
catalog = _load_catalog(catalog_path)
|
||||
discount = pct(_pick(config, "discount_pct", "discount", default=0))
|
||||
storage_price = _num(config.get("storage_economics") or {}, "adbs_storage_usd_per_gb_month", default=0)
|
||||
storage_price = storage_price or _sku_price(catalog, SKU_ADBS_STORAGE_ATP)
|
||||
specs = build_bom_specs(config)
|
||||
target_monthly = bom_monthly_total(specs["to_be_steady_state"], catalog_path)
|
||||
target = config.get("target") or {}
|
||||
# ECPU demand is not fixed storage infrastructure; remove it to approximate fixed footprint.
|
||||
discount_factor = 1 - discount
|
||||
fixed_monthly = (
|
||||
_sku_price(catalog, SKU_ADBD_BASE) * HOURS_PER_MONTH
|
||||
+ _sku_price(catalog, SKU_ADBD_DB_SERVER) * HOURS_PER_MONTH * _num(target, "db_nodes", "database_servers")
|
||||
+ _sku_price(catalog, SKU_ADBD_STORAGE_SERVER) * HOURS_PER_MONTH * _num(target, "storage_servers")
|
||||
) * discount_factor
|
||||
fixed_monthly = fixed_monthly or target_monthly
|
||||
break_even_tb = storage_break_even_tb(fixed_monthly, storage_price, discount)
|
||||
current_tb = _num(config.get("current") or {}, "storage_tb", default=0)
|
||||
storage_offset = current_tb * 1024 * storage_price * discount_factor
|
||||
base_break_even = _pick(config.get("storage_economics") or {}, "base_break_even_tb", default="")
|
||||
cards = [
|
||||
{
|
||||
"value": f"USD {storage_price * discount_factor:.4f}/GB-mo",
|
||||
"label": "ADB-S storage after discount",
|
||||
"detail": "Per-GB storage remains variable in the as-is model.",
|
||||
},
|
||||
{
|
||||
"value": f"{break_even_tb:,.0f} TB",
|
||||
"label": "Recalculated break-even",
|
||||
"detail": "Fixed dedicated footprint / ADB-S storage USD per GB-month.",
|
||||
},
|
||||
{
|
||||
"value": f"USD {storage_offset:,.0f}/mo",
|
||||
"label": "Storage offset",
|
||||
"detail": f"Current {current_tb:,.0f} TB storage avoided or absorbed by dedicated footprint.",
|
||||
},
|
||||
]
|
||||
if base_break_even:
|
||||
cards[1]["detail"] = f"Customer base break-even: {base_break_even} TB; recalculated for proposed footprint."
|
||||
return {
|
||||
"headline": "Storage economics: variable ADB-S storage offsets fixed ADB-D infrastructure",
|
||||
"cards": cards,
|
||||
"break_even_tb": break_even_tb,
|
||||
"fixed_monthly_cost": fixed_monthly,
|
||||
"storage_offset_monthly": storage_offset,
|
||||
}
|
||||
|
||||
|
||||
def build_crossover_chart(projection: list[dict], config: dict) -> dict:
|
||||
categories = [row["label"] for row in projection]
|
||||
as_is = [millions(row["as_is_annual_tco"]) for row in projection]
|
||||
to_be = [millions(row["to_be_annual_tco"]) for row in projection]
|
||||
crossover = first_crossover_period(projection)
|
||||
bullets = []
|
||||
for row in projection[:4]:
|
||||
delta = float(row["to_be_annual_tco"] or 0) - float(row["as_is_annual_tco"] or 0)
|
||||
sign = "+" if delta >= 0 else "-"
|
||||
bullets.append(f"{row['label']}: ADB-D {sign}USD {abs(delta):,.0f}/year")
|
||||
chart_cfg = config.get("crossover_chart") or {}
|
||||
return {
|
||||
"title": _pick(chart_cfg, "title", default="TCO Crossover"),
|
||||
"subtitle": "Annual run-rate comparison using equal workload ECPU demand.",
|
||||
"categories": categories,
|
||||
"as_is": as_is,
|
||||
"to_be": to_be,
|
||||
"as_is_label": _pick(config.get("current") or {}, "label", default="ADB-S As-Is"),
|
||||
"to_be_label": _pick(config.get("target") or {}, "label", default="ADB-D Dedicated"),
|
||||
"callout": f"Crossover: {crossover}" if crossover else "No crossover in modeled horizon",
|
||||
"bullets": bullets,
|
||||
"y_axis_min": _pick(chart_cfg, "y_axis_min", default=max(0, min(as_is + to_be) * 0.85 if as_is and to_be else 0)),
|
||||
"y_axis_max": _pick(chart_cfg, "y_axis_max", default=max(as_is + to_be) * 1.12 if as_is and to_be else 1),
|
||||
"y_axis_major_unit": _pick(chart_cfg, "y_axis_major_unit", default=0.5),
|
||||
"note": "Bars are native PowerPoint shapes for compatibility with lightweight renderers.",
|
||||
}
|
||||
|
||||
|
||||
def build_business_value(config: dict) -> dict:
|
||||
impact = config.get("business_impact") or {}
|
||||
can_monetize = any(_num(impact, key, default=0) for key in [
|
||||
"business_impact_per_hour",
|
||||
"revenue_at_risk_per_hour",
|
||||
"transaction_margin_per_hour",
|
||||
"fraud_loss_impact_per_hour",
|
||||
"cost_per_outage_hour",
|
||||
])
|
||||
treatment = (
|
||||
"Quantified only from customer-provided business impact per degraded/outage hour."
|
||||
if can_monetize
|
||||
else "Not converted to USD; customer business-impact input required."
|
||||
)
|
||||
rows = [
|
||||
{
|
||||
"area": "Financial baseline",
|
||||
"measure": "As-is run-rate, target run-rate, forecasted annual TCO.",
|
||||
"treatment": "Hard TCO only; no revenue impact invented.",
|
||||
},
|
||||
{
|
||||
"area": "Architecture benefit",
|
||||
"measure": "Local ADG read path, retired clones, explicit utilization headroom.",
|
||||
"treatment": "Explained separately from equal workload ECPU demand.",
|
||||
},
|
||||
{
|
||||
"area": "Risk-adjusted value",
|
||||
"measure": "avoided degraded/outage hours x business impact per hour",
|
||||
"treatment": treatment,
|
||||
},
|
||||
{
|
||||
"area": "Operational KPIs",
|
||||
"measure": "CPU utilization, read latency, ADG read lag, GoldenGate/apply lag, incidents avoided.",
|
||||
"treatment": "Tracked as success metrics after cutover.",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"title": "Business Value Model",
|
||||
"headline": "Risk-reduction value is explicit, not assumed as revenue uplift",
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def build_cost_breakdown(config: dict, projection: list[dict]) -> dict:
|
||||
if not projection:
|
||||
return {}
|
||||
current = projection[0]
|
||||
target = config.get("target") or {}
|
||||
current_monthly = float(current["as_is_annual_tco"]) / 12
|
||||
target_monthly = float(current["to_be_annual_tco"]) / 12
|
||||
bridge_cost = goldengate_bridge_term_cost(config)
|
||||
cap = capacity_model(target, _num(config, "ecpu_per_db_node", default=760))
|
||||
notes = [
|
||||
f"Workload ECPU demand is modeled separately from physical capacity: {cap.label}.",
|
||||
"Cloud services, storage/infra, GoldenGate, operations, and one-time bridge assumptions remain separate.",
|
||||
f"GoldenGate mode: {_pick(config.get('goldengate') or {}, 'mode', default='not provided')}.",
|
||||
]
|
||||
return {
|
||||
"title": "BOM + Operations Cost Breakdown",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": _pick(config.get("current") or {}, "label", default="ADB-S As-Is"),
|
||||
"lines": [
|
||||
{"item": "Cloud services", "monthly": money(current_monthly * 0.68), "annual": money(current_monthly * 0.68 * 12)},
|
||||
{"item": "Storage / infrastructure", "monthly": money(current_monthly * 0.24), "annual": money(current_monthly * 0.24 * 12)},
|
||||
{"item": "GoldenGate", "monthly": "As modeled", "annual": "As modeled"},
|
||||
{"item": "Operations", "monthly": "Assumption", "annual": "Assumption"},
|
||||
],
|
||||
"total": {"monthly": money(current_monthly), "annual": money(current["as_is_annual_tco"])},
|
||||
},
|
||||
{
|
||||
"name": _pick(config.get("target") or {}, "label", default="ADB-D Dedicated"),
|
||||
"lines": [
|
||||
{"item": "Cloud services", "monthly": money(target_monthly * 0.50), "annual": money(target_monthly * 0.50 * 12)},
|
||||
{"item": "Storage / infrastructure", "monthly": money(target_monthly * 0.42), "annual": money(target_monthly * 0.42 * 12)},
|
||||
{"item": "Migration bridge / one-time", "monthly": "Year-1 only", "annual": money(bridge_cost) if bridge_cost else "Year-1 only"},
|
||||
{"item": "Operations", "monthly": "Assumption", "annual": "Assumption"},
|
||||
],
|
||||
"total": {"monthly": money(target_monthly), "annual": money(current["to_be_annual_tco"])},
|
||||
},
|
||||
],
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
def enrich_adbs_to_adbd_business_case(spec: dict, catalog_path: str | None = None) -> dict:
|
||||
"""Return a copy of a business-case spec enriched with reusable model output."""
|
||||
root = deepcopy(spec)
|
||||
bc = root.get("business_case", root)
|
||||
config = bc.get("adbs_to_adbd") or bc.get("adb_s_to_adb_d")
|
||||
if not isinstance(config, dict):
|
||||
return root
|
||||
config = {**config, "customer_name": _pick(bc, "customer_name", "customer"), "prepared_by": _pick(bc, "prepared_by", "author")}
|
||||
projection = build_tco_projection(config, catalog_path)
|
||||
tco = dict(bc.get("tco") or {})
|
||||
tco.setdefault("comparison_label", "ADB-S As-Is vs ADB-D Dedicated")
|
||||
tco["projection"] = [
|
||||
{
|
||||
"period": row["label"],
|
||||
"cpu": row["cpu"],
|
||||
"storage": row["storage"],
|
||||
"as_is": row["as_is"],
|
||||
"to_be": row["to_be"],
|
||||
"delta": row["delta"],
|
||||
"note": row["note"],
|
||||
}
|
||||
for row in projection
|
||||
]
|
||||
if projection:
|
||||
tco["current_state"] = {
|
||||
"total_annual": projection[0]["as_is_annual_tco"],
|
||||
"annual_infrastructure": projection[0]["as_is_annual_tco"],
|
||||
}
|
||||
tco["proposed_oci"] = {
|
||||
"total_annual": projection[0]["to_be_annual_tco"],
|
||||
"annual_cloud_consumption": projection[0]["to_be_annual_tco"],
|
||||
"migration_one_time": _num(config.get("goldengate") or {}, "bridge_one_time_cost", default=0) or goldengate_bridge_term_cost(config, catalog_path),
|
||||
}
|
||||
tco["savings"] = {"annual": projection[0]["as_is_annual_tco"] - projection[0]["to_be_annual_tco"]}
|
||||
tco["breakdown"] = build_cost_breakdown(config, projection)
|
||||
tco["storage_economics"] = build_storage_economics(config, catalog_path)
|
||||
tco["crossover_chart"] = build_crossover_chart(projection, config)
|
||||
tco["business_value"] = build_business_value(config)
|
||||
tco["assumptions"] = [
|
||||
f"BYOL/PAYG model: {_pick(config, 'license_model', default='BYOL')}.",
|
||||
f"Discount: {pct(_pick(config, 'discount_pct', default=0)):.0%}.",
|
||||
f"Workload ECPU demand: {_num(config.get('current') or {}, 'workload_ecpu', 'ecpu_demand'):,.0f}.",
|
||||
capacity_model(config.get("target") or {}, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
f"Storage break-even: {tco['storage_economics']['break_even_tb']:,.0f} TB.",
|
||||
f"Crossover period: {first_crossover_period(projection) or 'not reached in modeled horizon'}.",
|
||||
f"GoldenGate bridge duration: {_pick(config.get('goldengate') or {}, 'bridge_months', 'fallback_months', default='not provided')}.",
|
||||
]
|
||||
bc["tco"] = tco
|
||||
bc.setdefault("roi", {
|
||||
"headline": "Business Value Model",
|
||||
"label": "Risk-adjusted value requires customer-provided business impact.",
|
||||
"cards": [
|
||||
{"title": "Financial baseline", "metric": "Run-rate TCO", "detail": "As-is, to-be, and forecasted annual run-rate are separated."},
|
||||
{"title": "Architecture benefit", "metric": "Read path simplified", "detail": "ADB-D Local ADG read-only standby can retire steady-state clones when applicable."},
|
||||
{"title": "Risk value", "metric": "No invented revenue", "detail": "Use avoided degraded/outage hours x customer-provided impact per hour."},
|
||||
],
|
||||
})
|
||||
bc.setdefault("value_drivers", [
|
||||
{
|
||||
"category": "cost",
|
||||
"title": "Storage offset",
|
||||
"quantified": f"{tco['storage_economics']['break_even_tb']:,.0f} TB break-even",
|
||||
"description": "ADB-S per-GB storage is compared with the fixed ADB-D footprint.",
|
||||
},
|
||||
{
|
||||
"category": "risk_reduction",
|
||||
"title": "Dedicated capacity headroom",
|
||||
"quantified": capacity_model(config.get("target") or {}, _num(config, "ecpu_per_db_node", default=760)).label,
|
||||
"description": "Dedicated physical capacity is shown separately from workload demand.",
|
||||
},
|
||||
{
|
||||
"category": "operations",
|
||||
"title": "Read architecture cleanup",
|
||||
"quantified": "Retire clones when ADG read path is adopted",
|
||||
"description": "Read clones remain only where the application read path still requires them.",
|
||||
},
|
||||
])
|
||||
risks = bc.setdefault("risks", {})
|
||||
risks.setdefault("migration_risks", [
|
||||
{"risk": "Migration delay affects Year-1 bridge duration.", "mitigation": "Lock rehearsal plan and fallback window."},
|
||||
{"risk": "GoldenGate/apply lag during migration.", "mitigation": "Track apply lag during rehearsal and cutover."},
|
||||
{"risk": "Late capacity reservation.", "mitigation": "Reserve target DB and storage server footprint before cutover."},
|
||||
])
|
||||
risks.setdefault("do_nothing_risks", [
|
||||
{"risk": "Clone/read model remains in production.", "impact": "Operational complexity and lag remain part of steady-state."},
|
||||
{"risk": "Cutover rehearsal risk remains unresolved.", "impact": "Decision delay compresses migration validation time."},
|
||||
])
|
||||
if "business_case" in root:
|
||||
root["business_case"] = bc
|
||||
return root
|
||||
|
||||
|
||||
def write_bom_outputs(config: dict, output_dir: str | Path, catalog_path: str | None = None) -> list[Path]:
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
saved: list[Path] = []
|
||||
for name, bom_spec in build_bom_specs(config).items():
|
||||
yaml_path = output_path / f"{name}.yaml"
|
||||
xlsx_path = output_path / f"{name}.xlsx"
|
||||
import yaml
|
||||
yaml_path.write_text(yaml.safe_dump(bom_spec, sort_keys=False), encoding="utf-8")
|
||||
gen = OCIBomGenerator.from_spec(bom_spec, catalog_path=catalog_path)
|
||||
gen.save(str(xlsx_path))
|
||||
saved.extend([yaml_path, xlsx_path])
|
||||
return saved
|
||||
146
tools/pptx_validator.py
Normal file
146
tools/pptx_validator.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural PPTX text validator.
|
||||
|
||||
Reads text boxes, table cells, and grouped shapes so lightweight preview
|
||||
limitations do not hide real deck content from validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
|
||||
|
||||
def _shape_text(shape) -> list[str]:
|
||||
texts: list[str] = []
|
||||
if hasattr(shape, "text") and str(shape.text or "").strip():
|
||||
texts.append(str(shape.text).strip())
|
||||
if getattr(shape, "has_table", False):
|
||||
for row in shape.table.rows:
|
||||
for cell in row.cells:
|
||||
value = str(cell.text or "").strip()
|
||||
if value:
|
||||
texts.append(value)
|
||||
if getattr(shape, "shape_type", None) == MSO_SHAPE_TYPE.GROUP:
|
||||
for child in shape.shapes:
|
||||
texts.extend(_shape_text(child))
|
||||
elif hasattr(shape, "shapes"):
|
||||
for child in shape.shapes:
|
||||
texts.extend(_shape_text(child))
|
||||
return texts
|
||||
|
||||
|
||||
def extract_pptx_text(path: str | Path) -> dict[str, Any]:
|
||||
prs = Presentation(str(path))
|
||||
slides = []
|
||||
all_text: list[str] = []
|
||||
for idx, slide in enumerate(prs.slides, start=1):
|
||||
texts: list[str] = []
|
||||
for shape in slide.shapes:
|
||||
texts.extend(_shape_text(shape))
|
||||
clean = [text for text in texts if text]
|
||||
slides.append({"index": idx, "text": "\n".join(clean), "texts": clean})
|
||||
all_text.extend(clean)
|
||||
return {"slide_count": len(prs.slides), "slides": slides, "text": "\n".join(all_text)}
|
||||
|
||||
|
||||
def _contains(haystack: str, needle: str, case_sensitive: bool = False) -> bool:
|
||||
if case_sensitive:
|
||||
return needle in haystack
|
||||
return needle.lower() in haystack.lower()
|
||||
|
||||
|
||||
def validate_pptx(
|
||||
path: str | Path,
|
||||
*,
|
||||
expected_titles: list[str] | None = None,
|
||||
required_phrases: list[str] | None = None,
|
||||
forbidden_phrases: list[str] | None = None,
|
||||
min_slides: int | None = None,
|
||||
max_slides: int | None = None,
|
||||
exact_slides: int | None = None,
|
||||
disclaimer_last: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
extracted = extract_pptx_text(path)
|
||||
text = extracted["text"]
|
||||
issues: list[dict[str, str]] = []
|
||||
|
||||
slide_count = extracted["slide_count"]
|
||||
if exact_slides is not None and slide_count != exact_slides:
|
||||
issues.append({"code": "SLIDE_COUNT", "message": f"Expected {exact_slides} slides, found {slide_count}."})
|
||||
if min_slides is not None and slide_count < min_slides:
|
||||
issues.append({"code": "SLIDE_COUNT_MIN", "message": f"Expected at least {min_slides} slides, found {slide_count}."})
|
||||
if max_slides is not None and slide_count > max_slides:
|
||||
issues.append({"code": "SLIDE_COUNT_MAX", "message": f"Expected at most {max_slides} slides, found {slide_count}."})
|
||||
|
||||
for title in expected_titles or []:
|
||||
if not _contains(text, title, case_sensitive):
|
||||
issues.append({"code": "MISSING_TITLE", "message": f"Expected title not found: {title}"})
|
||||
|
||||
for phrase in required_phrases or []:
|
||||
if not _contains(text, phrase, case_sensitive):
|
||||
issues.append({"code": "MISSING_REQUIRED", "message": f"Required phrase not found: {phrase}"})
|
||||
|
||||
for phrase in forbidden_phrases or []:
|
||||
if _contains(text, phrase, case_sensitive):
|
||||
issues.append({"code": "FORBIDDEN_PHRASE", "message": f"Forbidden phrase found: {phrase}"})
|
||||
|
||||
if disclaimer_last:
|
||||
if not extracted["slides"]:
|
||||
issues.append({"code": "DISCLAIMER_LAST", "message": "Deck has no slides."})
|
||||
else:
|
||||
last_text = extracted["slides"][-1]["text"]
|
||||
if not _contains(last_text, "Commercial Disclaimer", case_sensitive):
|
||||
issues.append({"code": "DISCLAIMER_LAST", "message": "Commercial Disclaimer is not the last slide."})
|
||||
|
||||
return {
|
||||
"path": str(path),
|
||||
"status": "fail" if issues else "pass",
|
||||
"slide_count": slide_count,
|
||||
"issues": issues,
|
||||
"titles_checked": expected_titles or [],
|
||||
"required_checked": required_phrases or [],
|
||||
"forbidden_checked": forbidden_phrases or [],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate PPTX structure and text content.")
|
||||
parser.add_argument("--pptx", required=True, help="Path to PPTX file.")
|
||||
parser.add_argument("--expected-title", action="append", default=[])
|
||||
parser.add_argument("--required", action="append", default=[])
|
||||
parser.add_argument("--forbidden", action="append", default=[])
|
||||
parser.add_argument("--min-slides", type=int)
|
||||
parser.add_argument("--max-slides", type=int)
|
||||
parser.add_argument("--exact-slides", type=int)
|
||||
parser.add_argument("--disclaimer-last", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
report = validate_pptx(
|
||||
args.pptx,
|
||||
expected_titles=args.expected_title,
|
||||
required_phrases=args.required,
|
||||
forbidden_phrases=args.forbidden,
|
||||
min_slides=args.min_slides,
|
||||
max_slides=args.max_slides,
|
||||
exact_slides=args.exact_slides,
|
||||
disclaimer_last=args.disclaimer_last,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2))
|
||||
else:
|
||||
print(f"pptx validation {report['status']}: {report['slide_count']} slides")
|
||||
for issue in report["issues"]:
|
||||
print(f" - {issue['code']}: {issue['message']}")
|
||||
return 1 if report["status"] == "fail" else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user