Brand icons (Slack/Jira) in diagrams + BOM comparison deck
- brand_icon_catalog: shared resolver for non-OCI vendor assets (Slack, Jira, Jira Service Management) under kb/diagram/brand-icons; official > fallback; never hits the network at render time. - oci_diagram_gen + oci_pptx_diagram_gen: services and external actors accept `brand_icon:`, unlocking real multicloud / SaaS topologies beyond Oracle stencils. - build_bom_comparison_deck: customer-facing Oracle FY26 deck that compares N BOM specs with native PowerPoint charts and live SKU pricing. - README + SKILL.md: document the new capabilities and ship copy/paste demo prompts (native .pptx diagram, Slack→OIC→Jira flow, multicloud Postgres↔ADB-S, discount-aware BOM). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
685
tools/build_bom_comparison_deck.py
Normal file
685
tools/build_bom_comparison_deck.py
Normal file
@@ -0,0 +1,685 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build a short PowerPoint deck comparing multiple OCI BOM scenarios.
|
||||
|
||||
The input spec references one or more BOM YAML specs. The script resolves
|
||||
monthly and 12-month totals from the live SKU catalog and produces a
|
||||
customer-ready Oracle FY26 deck with native PowerPoint charts.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.chart import XL_CHART_TYPE, XL_LABEL_POSITION, XL_LEGEND_POSITION
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
try:
|
||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
except ModuleNotFoundError:
|
||||
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CATALOG_PATH = PROJECT_ROOT / "kb" / "pricing" / "oci-sku-catalog.yaml"
|
||||
DB_SERVER_SKU = "B110627"
|
||||
STORAGE_SERVER_SKU = "B110629"
|
||||
|
||||
|
||||
def _round_money(value: Decimal) -> Decimal:
|
||||
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _money_text(value: Decimal) -> str:
|
||||
return f"USD {value:,.2f}"
|
||||
|
||||
|
||||
def _pct_text(value: Decimal) -> str:
|
||||
sign = "+" if value > 0 else ""
|
||||
return f"{sign}{value:.1f}%"
|
||||
|
||||
|
||||
def _pct_abs_text(value: Decimal) -> str:
|
||||
return f"{abs(value):.1f}%"
|
||||
|
||||
|
||||
def _resolve_path(path_str: str) -> Path:
|
||||
path = Path(path_str)
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return PROJECT_ROOT / path
|
||||
|
||||
|
||||
def _load_catalog(path: Path) -> dict[str, dict]:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
catalog: dict[str, dict] = {}
|
||||
for category in raw.get("categories", {}).values():
|
||||
for sku in category.get("skus", []):
|
||||
catalog[str(sku["sku"])] = sku
|
||||
return catalog
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScenarioMetrics:
|
||||
scenario_id: str
|
||||
short_label: str
|
||||
title: str
|
||||
topology: str
|
||||
tradeoff: str
|
||||
accent: str
|
||||
term_months: int
|
||||
db_servers: int
|
||||
storage_servers: int
|
||||
ecpus: int
|
||||
monthly_total: Decimal
|
||||
term_total: Decimal
|
||||
infra_monthly: Decimal
|
||||
compute_monthly: Decimal
|
||||
delta_vs_baseline: Decimal = Decimal("0")
|
||||
delta_pct_vs_baseline: Decimal = Decimal("0")
|
||||
|
||||
|
||||
def _scenario_color(accent: str) -> RGBColor:
|
||||
return {
|
||||
"recommended": Colors.COPPER,
|
||||
"balanced": Colors.TEAL,
|
||||
"savings": Colors.FOREST,
|
||||
}.get(accent, Colors.TEAL)
|
||||
|
||||
|
||||
def _load_bom_metrics(spec_path: Path, meta: dict, catalog: dict[str, dict]) -> ScenarioMetrics:
|
||||
raw = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
|
||||
bom = raw.get("bom", raw)
|
||||
items = bom.get("line_items", [])
|
||||
|
||||
monthly_total = Decimal("0")
|
||||
term_total = Decimal("0")
|
||||
infra_monthly = Decimal("0")
|
||||
compute_monthly = Decimal("0")
|
||||
db_servers = Decimal("0")
|
||||
storage_servers = Decimal("0")
|
||||
ecpus = Decimal("0")
|
||||
months_seen: set[int] = set()
|
||||
|
||||
for item in items:
|
||||
sku = str(item["sku"])
|
||||
entry = catalog.get(sku)
|
||||
if not entry:
|
||||
raise KeyError(f"SKU {sku} not found in catalog while processing {spec_path}")
|
||||
|
||||
price = Decimal(str(entry.get("list_price_usd", 0)))
|
||||
qty = Decimal(str(item.get("qty", 0)))
|
||||
hours_units = Decimal(str(item.get("hours_units", entry.get("default_hours_units", 730))))
|
||||
months = int(item.get("months", 12))
|
||||
discount = Decimal(str(item.get("discount", 0)))
|
||||
line_monthly = price * qty * hours_units * (Decimal("1") - discount)
|
||||
line_term = line_monthly * Decimal(str(months))
|
||||
|
||||
monthly_total += line_monthly
|
||||
term_total += line_term
|
||||
months_seen.add(months)
|
||||
|
||||
if sku == DB_SERVER_SKU:
|
||||
db_servers += qty
|
||||
infra_monthly += line_monthly
|
||||
elif sku == STORAGE_SERVER_SKU:
|
||||
storage_servers += qty
|
||||
infra_monthly += line_monthly
|
||||
elif "ECPU" in str(entry.get("metric", "")).upper():
|
||||
ecpus += qty
|
||||
compute_monthly += line_monthly
|
||||
else:
|
||||
compute_monthly += line_monthly
|
||||
|
||||
term_months = max(months_seen) if months_seen else int(meta.get("term_months", 12))
|
||||
return ScenarioMetrics(
|
||||
scenario_id=meta["id"],
|
||||
short_label=meta.get("short_label", meta["id"]),
|
||||
title=meta["title"],
|
||||
topology=meta["topology"],
|
||||
tradeoff=meta["tradeoff"],
|
||||
accent=meta.get("accent", "balanced"),
|
||||
term_months=term_months,
|
||||
db_servers=int(db_servers),
|
||||
storage_servers=int(storage_servers),
|
||||
ecpus=int(ecpus),
|
||||
monthly_total=_round_money(monthly_total),
|
||||
term_total=_round_money(term_total),
|
||||
infra_monthly=_round_money(infra_monthly),
|
||||
compute_monthly=_round_money(compute_monthly),
|
||||
)
|
||||
|
||||
|
||||
class BOMComparisonDeck(OraclePresBase):
|
||||
TITLE_ACCENT_COLOR = Colors.TEAL
|
||||
MARGIN = Inches(0.55)
|
||||
|
||||
def __init__(self, spec: dict, scenarios: list[ScenarioMetrics]):
|
||||
super().__init__()
|
||||
self.spec = spec
|
||||
self.deck_meta = spec["deck"]
|
||||
self.scenarios = scenarios
|
||||
self.term_months = int(self.deck_meta.get("term_months", 12))
|
||||
|
||||
@property
|
||||
def _compute_is_constant(self) -> bool:
|
||||
return len({scenario.compute_monthly for scenario in self.scenarios}) == 1
|
||||
|
||||
def _add_footer_note(self, slide, text: str) -> None:
|
||||
box = self._add_textbox(
|
||||
slide,
|
||||
self.MARGIN,
|
||||
Inches(7.0),
|
||||
Inches(12.0),
|
||||
Inches(0.25),
|
||||
text=text,
|
||||
font_size=9,
|
||||
italic=True,
|
||||
color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
box.text_frame.word_wrap = True
|
||||
|
||||
def _add_pill(self, slide, left, top, width, text, fill_color: RGBColor) -> None:
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, Inches(0.3))
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = fill_color
|
||||
shape.line.fill.background()
|
||||
tf = shape.text_frame
|
||||
tf.clear()
|
||||
p = tf.paragraphs[0]
|
||||
p.text = text
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
p.font.name = self.FONT
|
||||
p.font.size = Pt(10)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = Colors.WHITE
|
||||
shape.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
|
||||
def add_cover(self) -> None:
|
||||
slide = self._add_layout_slide(Layouts.COVER_DARK)
|
||||
self._set_placeholder(slide, 0, self.deck_meta["title"])
|
||||
self._set_placeholder(slide, 1, self.deck_meta["subtitle"])
|
||||
disclaimer = self.deck_meta.get("commercial_disclaimer", {})
|
||||
cover_tag = disclaimer.get("cover_tag")
|
||||
if cover_tag:
|
||||
self._add_pill(slide, Inches(8.7), Inches(0.45), Inches(3.9), cover_tag, Colors.COPPER)
|
||||
|
||||
def add_summary_slide(self) -> None:
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Scenario Summary", margin=self.MARGIN)
|
||||
|
||||
if self._compute_is_constant:
|
||||
headline = (
|
||||
"All three scenarios carry the same 830 ECPU allocation. "
|
||||
"The cost delta comes from infrastructure footprint only."
|
||||
)
|
||||
else:
|
||||
headline = (
|
||||
"Recommended setup remains the architecture-led option. "
|
||||
"Combined 6x4 trims infrastructure. Combined 5x3 is the strongest cost-down option."
|
||||
)
|
||||
self._add_textbox(
|
||||
slide,
|
||||
self.MARGIN,
|
||||
Inches(1.2),
|
||||
Inches(12.0),
|
||||
Inches(0.5),
|
||||
text=headline,
|
||||
font_size=15,
|
||||
bold=True,
|
||||
color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
|
||||
card_lefts = [self.MARGIN, Inches(4.45), Inches(8.35)]
|
||||
card_width = Inches(3.85)
|
||||
card_top = Inches(1.9)
|
||||
card_height = Inches(4.55)
|
||||
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
|
||||
|
||||
for left, scenario in zip(card_lefts, self.scenarios):
|
||||
card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, card_top, card_width, card_height)
|
||||
card.fill.solid()
|
||||
card.fill.fore_color.rgb = Colors.WARM_BG
|
||||
card.line.color.rgb = _scenario_color(scenario.accent)
|
||||
card.line.width = Pt(1.5)
|
||||
|
||||
accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, card_top, card_width, Inches(0.18))
|
||||
accent.fill.solid()
|
||||
accent.fill.fore_color.rgb = _scenario_color(scenario.accent)
|
||||
accent.line.fill.background()
|
||||
|
||||
self._add_textbox(
|
||||
slide, left + Inches(0.18), card_top + Inches(0.28), card_width - Inches(0.36), Inches(0.55),
|
||||
text=scenario.title, font_size=18, bold=True, color=Colors.PRIMARY_TEXT, font_name=self.FONT_HEADING,
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, left + Inches(0.18), card_top + Inches(0.82), card_width - Inches(0.36), Inches(0.7),
|
||||
text=scenario.topology, font_size=11, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
tag_text = "Recommended" if scenario.scenario_id == baseline.scenario_id else (
|
||||
"Lowest cost" if scenario.monthly_total == min(s.monthly_total for s in self.scenarios) else "Alternative"
|
||||
)
|
||||
self._add_pill(slide, left + Inches(0.18), card_top + Inches(1.45), Inches(1.35), tag_text, _scenario_color(scenario.accent))
|
||||
|
||||
self._add_textbox(
|
||||
slide, left + Inches(0.18), card_top + Inches(1.92), card_width - Inches(0.36), Inches(0.32),
|
||||
text="Monthly", font_size=10, bold=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, left + Inches(0.18), card_top + Inches(2.18), card_width - Inches(0.36), Inches(0.42),
|
||||
text=_money_text(scenario.monthly_total), font_size=20, bold=True, color=_scenario_color(scenario.accent),
|
||||
)
|
||||
self._add_textbox(
|
||||
slide, left + Inches(0.18), card_top + Inches(2.65), card_width - Inches(0.36), Inches(0.3),
|
||||
text=f"{self.term_months}M total: {_money_text(scenario.term_total)}", font_size=10, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
if scenario.scenario_id == baseline.scenario_id:
|
||||
delta_text = "Baseline"
|
||||
delta_color = Colors.PRIMARY_TEXT
|
||||
elif scenario.delta_vs_baseline < 0:
|
||||
delta_text = (
|
||||
f"Savings vs {baseline.short_label}: "
|
||||
f"{_money_text(-scenario.delta_vs_baseline)} / mo "
|
||||
f"({_pct_abs_text(scenario.delta_pct_vs_baseline)} lower)"
|
||||
)
|
||||
delta_color = Colors.FOREST
|
||||
else:
|
||||
delta_text = (
|
||||
f"Premium vs {baseline.short_label}: "
|
||||
f"{_money_text(scenario.delta_vs_baseline)} / mo "
|
||||
f"({_pct_text(scenario.delta_pct_vs_baseline)})"
|
||||
)
|
||||
delta_color = Colors.PRIMARY_TEXT
|
||||
self._add_textbox(
|
||||
slide, left + Inches(0.18), card_top + Inches(3.05), card_width - Inches(0.36), Inches(0.45),
|
||||
text=delta_text, font_size=10, bold=True,
|
||||
color=delta_color,
|
||||
)
|
||||
|
||||
footprint = (
|
||||
f"DB servers: {scenario.db_servers}\n"
|
||||
f"Storage servers: {scenario.storage_servers}\n"
|
||||
f"Total ECPU: {scenario.ecpus:,}"
|
||||
)
|
||||
tf_box = self._add_textbox(
|
||||
slide, left + Inches(0.18), card_top + Inches(3.55), card_width - Inches(0.36), Inches(0.72),
|
||||
text=footprint, font_size=10, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
tf_box.text_frame.word_wrap = True
|
||||
|
||||
self._add_textbox(
|
||||
slide, left + Inches(0.18), card_top + Inches(4.15), card_width - Inches(0.36), Inches(0.32),
|
||||
text=scenario.tradeoff, font_size=10, italic=True, color=Colors.SECONDARY_TEXT,
|
||||
)
|
||||
|
||||
self._add_footer_note(slide, "Source footprint: recommended and combined options from the reference deck. Pricing based on OCI list price, 730 hours/month, no commercial discount.")
|
||||
|
||||
def add_monthly_chart_slide(self) -> None:
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Monthly Cost Comparison", margin=self.MARGIN)
|
||||
|
||||
data = CategoryChartData()
|
||||
data.categories = [s.short_label for s in self.scenarios]
|
||||
data.add_series("Monthly USD", [float(s.monthly_total) for s in self.scenarios])
|
||||
frame = slide.shapes.add_chart(
|
||||
XL_CHART_TYPE.COLUMN_CLUSTERED,
|
||||
self.MARGIN,
|
||||
Inches(1.35),
|
||||
Inches(7.55),
|
||||
Inches(4.9),
|
||||
data,
|
||||
)
|
||||
chart = frame.chart
|
||||
chart.has_legend = False
|
||||
chart.value_axis.has_major_gridlines = True
|
||||
chart.value_axis.tick_labels.number_format = '$0,"K"'
|
||||
chart.value_axis.format.line.color.rgb = Colors.SECONDARY_TEXT
|
||||
chart.category_axis.format.line.color.rgb = Colors.SECONDARY_TEXT
|
||||
|
||||
plot = chart.plots[0]
|
||||
plot.vary_by_categories = True
|
||||
plot.has_data_labels = True
|
||||
plot.data_labels.number_format = '$#,##0'
|
||||
plot.data_labels.position = XL_LABEL_POSITION.OUTSIDE_END
|
||||
|
||||
series = chart.series[0]
|
||||
for point, scenario in zip(series.points, self.scenarios):
|
||||
point.format.fill.solid()
|
||||
point.format.fill.fore_color.rgb = _scenario_color(scenario.accent)
|
||||
point.format.line.color.rgb = _scenario_color(scenario.accent)
|
||||
|
||||
callout = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(8.2), Inches(1.45), Inches(4.5), Inches(4.6))
|
||||
callout.fill.solid()
|
||||
callout.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
callout.line.color.rgb = Colors.TEAL
|
||||
callout.line.width = Pt(1.2)
|
||||
|
||||
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
|
||||
cheapest = min(self.scenarios, key=lambda s: s.monthly_total)
|
||||
bullets = [
|
||||
f"{baseline.short_label} is still the architecture-led recommendation.",
|
||||
f"{self.scenarios[1].short_label} saves {_money_text(-self.scenarios[1].delta_vs_baseline)} per month by reducing storage footprint only.",
|
||||
f"{cheapest.short_label} saves {_money_text(-cheapest.delta_vs_baseline)} per month and is {_pct_abs_text(cheapest.delta_pct_vs_baseline)} below the baseline.",
|
||||
(
|
||||
f"{cheapest.short_label} is cheaper because it cuts both infrastructure and compute footprint."
|
||||
if not self._compute_is_constant
|
||||
else f"{cheapest.short_label} is cheaper exclusively because it uses the smallest infrastructure footprint."
|
||||
),
|
||||
]
|
||||
self._add_textbox(
|
||||
slide, Inches(8.42), Inches(1.7), Inches(3.95), Inches(0.35),
|
||||
text="Key takeaways", font_size=16, bold=True, color=Colors.PRIMARY_TEXT, font_name=self.FONT_HEADING,
|
||||
)
|
||||
notes_box = self._add_textbox(slide, Inches(8.35), Inches(2.15), Inches(3.95), Inches(3.4), text="", font_size=11)
|
||||
tf = notes_box.text_frame
|
||||
tf.clear()
|
||||
for idx, bullet in enumerate(bullets):
|
||||
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
||||
p.text = f"• {bullet}"
|
||||
p.font.name = self.FONT
|
||||
p.font.size = Pt(11)
|
||||
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||
p.space_after = Pt(8)
|
||||
|
||||
self._add_footer_note(slide, "This chart shows monthly run-rate only. The same ranking holds for the 12-month view because all scenarios use the same 730-hour monthly convention and 12-month term.")
|
||||
|
||||
def add_composition_slide(self) -> None:
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Cost Composition: Infrastructure vs ECPU", margin=self.MARGIN)
|
||||
|
||||
data = CategoryChartData()
|
||||
data.categories = [s.short_label for s in self.scenarios]
|
||||
data.add_series("Infrastructure", [float(s.infra_monthly) for s in self.scenarios])
|
||||
data.add_series("ADB Dedicated ECPU BYOL", [float(s.compute_monthly) for s in self.scenarios])
|
||||
|
||||
frame = slide.shapes.add_chart(
|
||||
XL_CHART_TYPE.COLUMN_STACKED,
|
||||
self.MARGIN,
|
||||
Inches(1.35),
|
||||
Inches(7.55),
|
||||
Inches(4.8),
|
||||
data,
|
||||
)
|
||||
chart = frame.chart
|
||||
chart.has_legend = True
|
||||
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
|
||||
chart.legend.include_in_layout = False
|
||||
chart.value_axis.has_major_gridlines = True
|
||||
chart.value_axis.tick_labels.number_format = '$0,"K"'
|
||||
|
||||
series_infra = chart.series[0]
|
||||
series_infra.format.fill.solid()
|
||||
series_infra.format.fill.fore_color.rgb = Colors.MUTED_TEAL
|
||||
series_infra.format.line.color.rgb = Colors.MUTED_TEAL
|
||||
|
||||
series_compute = chart.series[1]
|
||||
series_compute.format.fill.solid()
|
||||
series_compute.format.fill.fore_color.rgb = Colors.COPPER
|
||||
series_compute.format.line.color.rgb = Colors.COPPER
|
||||
|
||||
table = self._add_table(
|
||||
slide,
|
||||
len(self.scenarios) + 1,
|
||||
4,
|
||||
Inches(8.2),
|
||||
Inches(1.6),
|
||||
Inches(4.4),
|
||||
Inches(2.4),
|
||||
)
|
||||
headers = ["Scenario", "Infra / mo", "ECPU / mo", "Infra share"]
|
||||
widths = [1.2, 1.05, 1.1, 1.0]
|
||||
for idx, width in enumerate(widths):
|
||||
table.columns[idx].width = Inches(width)
|
||||
for col, header in enumerate(headers):
|
||||
self._style_table_cell(
|
||||
table.cell(0, col), header,
|
||||
font_size=10, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER if col > 0 else PP_ALIGN.LEFT,
|
||||
)
|
||||
|
||||
for row_idx, scenario in enumerate(self.scenarios, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||
infra_share = (scenario.infra_monthly / scenario.monthly_total * Decimal("100")) if scenario.monthly_total else Decimal("0")
|
||||
self._style_table_cell(table.cell(row_idx, 0), scenario.short_label, font_size=10, bold=True, bg_color=bg)
|
||||
self._style_table_cell(table.cell(row_idx, 1), _money_text(scenario.infra_monthly), font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(row_idx, 2), _money_text(scenario.compute_monthly), font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
self._style_table_cell(table.cell(row_idx, 3), f"{infra_share:.1f}%", font_size=9, bg_color=bg, alignment=PP_ALIGN.RIGHT)
|
||||
|
||||
insight = (
|
||||
"The ECPU line is flat across all three scenarios: 830 total ECPU.\n"
|
||||
"Only the DB server and storage server counts move the price."
|
||||
if self._compute_is_constant
|
||||
else
|
||||
"Combined 6x4 changes only the infrastructure layer.\n"
|
||||
"Combined 5x3 is materially cheaper because it also reduces the ECPU footprint."
|
||||
)
|
||||
callout = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(8.2), Inches(4.35), Inches(4.4), Inches(1.15))
|
||||
callout.fill.solid()
|
||||
callout.fill.fore_color.rgb = Colors.WARM_BG
|
||||
callout.line.color.rgb = Colors.COPPER
|
||||
self._add_textbox(
|
||||
slide, Inches(8.42), Inches(4.58), Inches(4.0), Inches(0.7),
|
||||
text=insight, font_size=11, bold=True, color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
|
||||
self._add_footer_note(slide, "Infrastructure is the sum of X11M DB servers plus X11M storage servers. Compute is the ADB Dedicated BYOL ECPU line.")
|
||||
|
||||
def add_detail_table_slide(self) -> None:
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, "Detailed BOM Comparison", margin=self.MARGIN)
|
||||
|
||||
table = self._add_table(
|
||||
slide,
|
||||
len(self.scenarios) + 1,
|
||||
8,
|
||||
self.MARGIN,
|
||||
Inches(1.4),
|
||||
Inches(12.2),
|
||||
Inches(3.1),
|
||||
)
|
||||
widths = [1.5, 3.0, 0.9, 1.1, 1.1, 1.35, 1.35, 1.9]
|
||||
for idx, width in enumerate(widths):
|
||||
table.columns[idx].width = Inches(width)
|
||||
headers = ["Scenario", "Topology", "DB", "Storage", "ECPU", "Monthly", f"{self.term_months}M total", "Trade-off"]
|
||||
for col, header in enumerate(headers):
|
||||
self._style_table_cell(
|
||||
table.cell(0, col), header,
|
||||
font_size=10, bold=True, color=Colors.WHITE, bg_color=Colors.TEAL,
|
||||
alignment=PP_ALIGN.CENTER if col not in (0, 1, 7) else PP_ALIGN.LEFT,
|
||||
)
|
||||
|
||||
for row_idx, scenario in enumerate(self.scenarios, start=1):
|
||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||
if scenario.scenario_id == self.deck_meta["baseline_scenario_id"]:
|
||||
bg = Colors.TEAL
|
||||
txt = Colors.WHITE
|
||||
else:
|
||||
txt = Colors.PRIMARY_TEXT
|
||||
|
||||
values = [
|
||||
scenario.short_label,
|
||||
scenario.topology,
|
||||
str(scenario.db_servers),
|
||||
str(scenario.storage_servers),
|
||||
f"{scenario.ecpus:,}",
|
||||
_money_text(scenario.monthly_total),
|
||||
_money_text(scenario.term_total),
|
||||
scenario.tradeoff,
|
||||
]
|
||||
aligns = [PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.CENTER, PP_ALIGN.CENTER, PP_ALIGN.RIGHT, PP_ALIGN.RIGHT, PP_ALIGN.RIGHT, PP_ALIGN.LEFT]
|
||||
for col_idx, (value, align) in enumerate(zip(values, aligns)):
|
||||
self._style_table_cell(
|
||||
table.cell(row_idx, col_idx), value,
|
||||
font_size=9, bold=(scenario.scenario_id == self.deck_meta["baseline_scenario_id"]),
|
||||
color=txt, bg_color=bg, alignment=align,
|
||||
)
|
||||
|
||||
deltas_box = self._add_textbox(slide, self.MARGIN, Inches(5.0), Inches(12.0), Inches(1.0), text="", font_size=10)
|
||||
tf = deltas_box.text_frame
|
||||
tf.clear()
|
||||
baseline = next(s for s in self.scenarios if s.scenario_id == self.deck_meta["baseline_scenario_id"])
|
||||
summary_lines = [
|
||||
f"Baseline: {baseline.short_label} at {_money_text(baseline.monthly_total)} per month.",
|
||||
f"{self.scenarios[1].short_label}: {_money_text(-self.scenarios[1].delta_vs_baseline)} per month lower than baseline.",
|
||||
f"{self.scenarios[2].short_label}: {_money_text(-self.scenarios[2].delta_vs_baseline)} per month lower than baseline.",
|
||||
]
|
||||
for idx, line in enumerate(summary_lines):
|
||||
p = tf.paragraphs[0] if idx == 0 else tf.add_paragraph()
|
||||
p.text = line
|
||||
p.font.name = self.FONT
|
||||
p.font.size = Pt(10)
|
||||
p.font.color.rgb = Colors.SECONDARY_TEXT
|
||||
p.space_after = Pt(5)
|
||||
|
||||
self._add_footer_note(slide, "BOM workbook references are saved alongside this deck for traceability and recalculation.")
|
||||
|
||||
def add_disclaimer_slide(self) -> None:
|
||||
disclaimer = self.deck_meta.get("commercial_disclaimer", {})
|
||||
if not disclaimer:
|
||||
return
|
||||
|
||||
slide = self._add_blank_slide()
|
||||
self._add_title_bar(slide, disclaimer.get("title", "Commercial Disclaimer"), margin=self.MARGIN)
|
||||
|
||||
intro_box = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
self.MARGIN,
|
||||
Inches(1.35),
|
||||
Inches(12.1),
|
||||
Inches(2.15),
|
||||
)
|
||||
intro_box.fill.solid()
|
||||
intro_box.fill.fore_color.rgb = Colors.WARM_BG
|
||||
intro_box.line.color.rgb = Colors.COPPER
|
||||
intro_box.line.width = Pt(1.2)
|
||||
|
||||
formal = disclaimer.get("formal_text", "")
|
||||
formal_tb = self._add_textbox(
|
||||
slide,
|
||||
Inches(0.8),
|
||||
Inches(1.58),
|
||||
Inches(11.55),
|
||||
Inches(1.7),
|
||||
text="",
|
||||
font_size=11,
|
||||
color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
formal_tf = formal_tb.text_frame
|
||||
formal_tf.clear()
|
||||
p = formal_tf.paragraphs[0]
|
||||
p.text = formal
|
||||
p.font.name = self.FONT
|
||||
p.font.size = Pt(11)
|
||||
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
p.space_after = Pt(0)
|
||||
formal_tf.word_wrap = True
|
||||
|
||||
note_box = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
self.MARGIN,
|
||||
Inches(3.95),
|
||||
Inches(12.1),
|
||||
Inches(2.15),
|
||||
)
|
||||
note_box.fill.solid()
|
||||
note_box.fill.fore_color.rgb = Colors.TABLE_ALT_ROW
|
||||
note_box.line.color.rgb = Colors.TEAL
|
||||
note_box.line.width = Pt(1.2)
|
||||
|
||||
self._add_textbox(
|
||||
slide,
|
||||
Inches(0.8),
|
||||
Inches(4.18),
|
||||
Inches(3.2),
|
||||
Inches(0.35),
|
||||
text="Practical notes",
|
||||
font_size=16,
|
||||
bold=True,
|
||||
color=Colors.PRIMARY_TEXT,
|
||||
font_name=self.FONT_HEADING,
|
||||
)
|
||||
bullets_tb = self._add_textbox(
|
||||
slide,
|
||||
Inches(0.8),
|
||||
Inches(4.55),
|
||||
Inches(11.2),
|
||||
Inches(1.25),
|
||||
text="",
|
||||
font_size=11,
|
||||
color=Colors.PRIMARY_TEXT,
|
||||
)
|
||||
bullets_tf = bullets_tb.text_frame
|
||||
bullets_tf.clear()
|
||||
for idx, bullet in enumerate(disclaimer.get("bullets", [])):
|
||||
p = bullets_tf.paragraphs[0] if idx == 0 else bullets_tf.add_paragraph()
|
||||
p.text = f"• {bullet}"
|
||||
p.font.name = self.FONT
|
||||
p.font.size = Pt(11)
|
||||
p.font.color.rgb = Colors.PRIMARY_TEXT
|
||||
p.space_after = Pt(10)
|
||||
|
||||
self._add_footer_note(
|
||||
slide,
|
||||
"Reference pricing only. Formal commercials must move through the Oracle quoting process and the applicable agreement path.",
|
||||
)
|
||||
|
||||
def build(self) -> None:
|
||||
self.add_cover()
|
||||
self.add_summary_slide()
|
||||
self.add_monthly_chart_slide()
|
||||
self.add_composition_slide()
|
||||
self.add_detail_table_slide()
|
||||
self.add_disclaimer_slide()
|
||||
|
||||
|
||||
def load_deck_inputs(spec_path: Path) -> tuple[dict, list[ScenarioMetrics]]:
|
||||
spec = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
|
||||
deck = spec["deck"]
|
||||
catalog = _load_catalog(CATALOG_PATH)
|
||||
scenarios = []
|
||||
for scenario_meta in deck.get("scenarios", []):
|
||||
bom_spec = _resolve_path(scenario_meta["bom_spec"])
|
||||
scenarios.append(_load_bom_metrics(bom_spec, scenario_meta, catalog))
|
||||
|
||||
baseline_id = deck["baseline_scenario_id"]
|
||||
baseline = next(s for s in scenarios if s.scenario_id == baseline_id)
|
||||
for scenario in scenarios:
|
||||
scenario.delta_vs_baseline = _round_money(scenario.monthly_total - baseline.monthly_total)
|
||||
if baseline.monthly_total:
|
||||
pct = (scenario.monthly_total - baseline.monthly_total) / baseline.monthly_total * Decimal("100")
|
||||
scenario.delta_pct_vs_baseline = Decimal(str(pct)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
|
||||
return spec, scenarios
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build a comparison deck from multiple BOM specs.")
|
||||
parser.add_argument("--spec", required=True, help="YAML deck spec path")
|
||||
parser.add_argument("--output", required=True, help="Output .pptx path")
|
||||
args = parser.parse_args()
|
||||
|
||||
spec_path = _resolve_path(args.spec)
|
||||
output_path = _resolve_path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
spec, scenarios = load_deck_inputs(spec_path)
|
||||
deck = BOMComparisonDeck(spec, scenarios)
|
||||
deck.build()
|
||||
deck.save(str(output_path))
|
||||
print(f"Deck saved: {output_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user