2 Commits

Author SHA1 Message Date
sku-refresh-bot
6aea208129 chore(pricing): monthly SKU catalog refresh (2026-07-01)
Automated refresh from Oracle public pricing API.
See workflow artifact 'sku-refresh-logs' for the diff.
2026-07-01 09:00:45 +00:00
root
4552ef7226 Enhance ADB-S to ADB-D business cases 2026-05-08 15:25:21 -03:00
13 changed files with 1900 additions and 62 deletions

View File

@@ -86,7 +86,7 @@ Present these options as a compact numbered list. Each option has a bold title f
DESIGN & PROPOSE
─────────────────
1. 📋 Full proposal — *notes → architecture + deck + native PPTX diagram + costs*
2. 📐 Architecture diagram — *description → .drawio or native PPTX*
2. 📐 Architecture diagram — *description/sketch → .excalidraw → .drawio or native PPTX*
3. 📊 Slide deck — *architecture → .pptx with native OCI diagram*
4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
@@ -124,6 +124,7 @@ Pick a number, or just describe what you need.
If the user asks for examples, or skips the menu and writes a natural-language request, prompts like these map directly to the capabilities above:
- `Generame un diagrama nativo en .pptx, simple y prolijo, de esta arquitectura: usuarios externos -> load balancer público -> 2 VMs de aplicación en subnet privada -> Autonomous Database Serverless. Quiero que quede presentable para mostrar directo en PowerPoint. No me pidas YAML ni setup adicional.`
- `Sketch this architecture first for Excalidraw: external users -> public load balancer -> private app VMs -> Autonomous Database. Give me a native .excalidraw file so I can review and collaborate before making the formal diagram.`
- `Generate a simple high-level integration diagram for this flow: Slack -> Oracle Integration Cloud (OIC) -> Jira Service Management -> My Oracle Support. Add clear Client, Oracle, and External layers. Use brand icons for Slack and Jira Service Management, and deliver both .drawio and .pptx.`
- `Generate a high-level architecture diagram for this scenario: 1 PostgreSQL in OCI Ashburn, accessed from GCP Virginia through interconnect. PostgreSQL connects internally to 1 Autonomous Database Serverless, which also has 1 refreshable clone in the same region but in a different AD. Deliver both .drawio and .pptx. Keep it simple, executive-friendly, and technically correct.`
- `Gere um BOM em USD para este cenário: PostgreSQL no OCI com 4 OCPU e 500 GB, ADB-S com 200 ECPU e 1 TB BYOL, 1 refreshable clone com o mesmo sizing e FastConnect de 1 Gbps. Aplique 11% de desconto, 12 meses, 24 horas por dia. Quero o arquivo .xlsx e um resumo claro do custo mensal e anual.`
@@ -136,15 +137,29 @@ If the user asks for examples, or skips the menu and writes a natural-language r
```
1. Describe the architecture you want to diagram (or paste a YAML spec if you have one).
2. Which output format(s) do you want? Pick one or more:
(a) .drawio — editable technical diagram
(b) .pptx — native Oracle-style PowerPoint diagram/slide
(c) both
(a) sketch — native .excalidraw whiteboard draft for brainstorming
(b) .drawio — editable technical diagram
(c) .pptx — native Oracle-style PowerPoint diagram/slide
(d) sketch + .drawio
(e) sketch + .pptx
(f) all
```
After the user answers, **follow these steps in order — do NOT skip step 1**:
If the user chooses **sketch only**, create a concise sketch spec and run `python tools/oci_excalidraw_gen.py --spec <sketch-spec.yaml> --output <output-dir>/architecture-sketch.excalidraw`. Save the generated `.excalidraw` file and companion `.md` instructions in the engagement output folder. Present the file list and the simple import/share workflow from the companion `.md` as the primary deliverable. Optionally save `architecture-sketch.mmd` as a secondary source when Mermaid is useful, but the native `.excalidraw` file is the draft artifact. Do not run the formal `absolute_layout` renderer unless the user asks to promote the sketch to `.drawio` or `.pptx`.
If the user chooses **sketch plus formal output**, produce the `.excalidraw` draft first and ask the user to confirm or adjust the intent before formal rendering. If the user explicitly says to generate all outputs without another checkpoint, generate the sketch and continue. If the user later uploads or references an edited `.excalidraw` from Excalidraw.com, treat it as visual discovery/reference input for the formal `.drawio` / `.pptx` flow; do not blindly convert its rough geometry into the final source of truth.
If the user provides an existing `.excalidraw` file and asks to convert it to `.drawio`, default to **Formal OCI diagram** unless they explicitly ask for a literal/whiteboard copy:
- **Hard rule:** do **not** hand-roll draw.io XML with inline Python for this path. Use the repo tools below. If `python` is not available, use `.venv/bin/python` or `python3`; do not fall back to a custom converter.
- **Formal OCI diagram (default)** — run `.venv/bin/python tools/excalidraw_to_oci_diagram.py --input <file.excalidraw> --output <output-dir>/<name>-oci.drawio --spec-output <output-dir>/<name>-oci.yaml`. This extracts the approved sketch intent, writes `*-analysis.yaml` and `*-review.md`, generates an `absolute_layout` YAML, then renders a new Oracle-style `.drawio` through `oci_diagram_gen.py` with OCI containers, icons, connector styles, and validators. For complex sketches (multicloud, hub-spoke, migration/cutover, cross-region DR, multiple regions/VCNs), run the same command with `--mode review` first, inspect/refine the generated analysis/spec, then render.
- **Literal sketch conversion (secondary)** — run `.venv/bin/python tools/excalidraw_to_drawio.py --input <file.excalidraw> --output <output-dir>/<name>-literal.drawio` only when the user asks to preserve the exact whiteboard look. This is an editable bridge artifact and does **not** infer OCI icons/services.
- **Both** — produce the formal OCI `.drawio` first, then the literal bridge if useful for comparison.
For any `.drawio` or `.pptx` output, **follow these steps in order — do NOT skip step 1**:
1. **Reference-architecture lookup.** Run `python tools/archcenter_pattern_lookup.py "<topology keywords>"` against `kb/architecture-center/catalog.yaml` (123 Oracle-curated entries with cached `.drawio` / `_description.md` / `_template.yaml` under `kb/diagram/assets/archcenter-refs/`). Pick the highest-scoring entry whose topology matches.
- **The cached `_template.yaml` is your YAML SCAFFOLD** — auto-extracted from the canonical `.drawio` by `tools/archcenter_drawio_to_template.py`. It carries Oracle's container geometry (region/vcn/ad/subnet bboxes), service positions, and edge waypoints in the `absolute_layout:` shape your spec uses. Copy it as the starting point, rename ids, fill in `type:` for services (the extractor leaves `type: TODO_identify` because the canonical icon TYPE is encoded in stencil bytes — pick the right alias from the renderer's TYPE_TO_ICON), and adapt to the customer's components. Lookup result surfaces it as `cached: yaml=<path>`.
- **The cached `.drawio` is the visual source of truth** if the template extractor missed something — open it (drawio.exe, the SVG companion, or the XML) for the canonical look.
- **Migration/cutover + multicloud templates.** Also check `kb/diagram/templates/catalog.yaml`. Use `renderable_templates` when the user asks for an operational cutover/runbook view (for example ADB-S → ADB-D with OGG forward/reverse replication) instead of a canonical steady-state Architecture Center topology.
- **Do NOT use `examples/` as a geometry source.** `examples/` is previous user output, not authoritative. The `_template.yaml` covers everything `examples/` was being abused for.
- **Lookup budget: max 2 queries — never loop.** If query 1 returns nothing convincing, run ONE refinement (broader phrasing or different keyword angle), then STOP. Re-querying the catalog 3+ times to find a "better" match is the dominant time-sink in this phase — the catalog has 123 entries indexed once; if two passes can't surface a topology match, none exists. Output of the lookup tool also surfaces a **`⚠ KNOWN GAP`** banner at the top whenever the query touches a documented gap (e.g. OCI-native services accessed FROM GCP via Cross-Cloud Interconnect, or OCI Cache/Redis/PostgreSQL as primary subject). When that banner appears, follow its **Recommended composition** verbatim instead of running more queries — it lists the primitives to combine. When NO gap banner appears AND no top-3 entry matches the topology, present the closest 3 to the user with: *"No exact ref-arch matches. Closest hits are X / Y / Z. I can either base the diagram on the closest of these (geometry transfers, service names don't), or compose from `<list specific slugs / patterns>`. Which?"* — let the user pick the chassis instead of guessing.
2. **Pre-generation review.** Confirm the component list with the user (REQUESTED + TECHNICAL DEPENDENCIES per the whitelist).
@@ -187,14 +202,22 @@ If the user asks for examples, or skips the menu and writes a natural-language r
1. Parse input into `templates/business-case.yaml` structure
2. Identify business drivers and urgency from discovery notes
3. Estimate TCO comparison using `kb/pricing/oci-sku-catalog.yaml` (live SKU prices), `kb/pricing/compute.yaml` (shape-level), `kb/field-knowledge/pricing-knowledge.yaml` (billing models, BYOL rules, free tiers), and `kb/sizing/*`
4. Calculate ROI and payback period
5. Map value drivers (cost, risk, agility, innovation) with quantified evidence
6. Assess migration risks from `kb/field-knowledge/gotchas.yaml` and do-nothing risks
7. Compare with alternatives using `kb/competitive/*`
8. Generate implementation roadmap based on engagement tier
9. Produce a 8-10 slide deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), using a native OCI PowerPoint diagram when the architecture is structured enough
10. Output: business-case.pptx + business-case.yaml (reusable spec)
11. **Disclaimer + deck verification.** Append the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` as a final "Commercial Disclaimer" slide (verbatim — see option 4 step 7). Then run the same structural deck verification described in option 4 step 8 (slide count, titles, chart types, disclaimer-last) before reporting completion.
4. For ADB-S to ADB-D cases, use the reusable `adbs_to_adbd` model:
- keep workload/billable ECPU demand separate from dedicated physical capacity (`DB nodes × ECPU per DB node`)
- do not assume ADB-D uses 100% physical capacity; show utilization explicitly
- keep workload demand comparable between ADB-S and ADB-D unless customer data says otherwise
- model ADB-D fixed DB server + storage server infrastructure separately from workload ECPU
- keep ADB-S as-is components explicit: primaries, local ADG, cross-region standby, refreshable/read clones, read clones, GoldenGate
5. Separate financial views: current/as-is run-rate, target/to-be run-rate, forecasted TCO by horizon, and scenario comparison when applicable. Avoid ambiguous labels such as `OCI Annual`; use scenario labels like `ADB-S As-Is`, `ADB-D Dedicated`, `Incremental Investment`, and `TCO Crossover`.
6. Generate companion BOMs for current as-is, to-be steady state, and 24M/36M projected as-is/to-be annual run-rate snapshots. Projected BOMs are not cumulative multi-year totals.
7. Model read architecture explicitly: ADB-S may require refreshable/read clones because standby is not the application read path; ADB-D may use Local ADG read-only standby and retire steady-state clones. Reflect this in BOM notes, value drivers, and risk/value slides.
8. Add storage economics: ADB-S per-GB storage, ADB-D fixed footprint, customer-provided base break-even when available, recalculated break-even for the proposed footprint, and visible storage offset.
9. Treat GoldenGate as `steady_state`, `migration_bridge_only`, or `migration_plus_fallback_months`. Bridge-only GoldenGate is Year-1 / one-time and is excluded from future steady-state BOMs.
10. Add a TCO crossover chart when multi-year forecast exists, defaulting to native PowerPoint grouped bars with executive labels (`Near-term`, `Year 1`, `Year 2`, `Year 3`) and delta bullets.
11. For risk-reduction cases, generate a Business Value Model instead of generic ROI percentages. Do not invent revenue impact; convert stability to USD only when the customer provides revenue-at-risk, transaction margin, fraud loss impact, or cost per degraded/outage hour.
12. Assess migration and inaction risks against the selected scenario only. Remove obsolete risks such as `if 4x3 is selected` after the customer has chosen `5x3`.
13. Produce an executive deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), plus business-case YAML and generated BOM artefacts where applicable.
14. **Disclaimer + deck verification.** Append the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` as a final "Commercial Disclaimer" slide (verbatim — see option 4 step 7). Then run structural PPTX validation that reads text boxes, table cells, and grouped shapes; check slide count/titles, disclaimer-last, required assumptions, and forbidden obsolete phrases before reporting completion.
- If the user picks **9**, ask: "What topic? (e.g., 'DEP', 'TAC', 'maintenance window', 'vector search')"
- If the user picks **10**, ask: "What kind of architecture? (e.g., 'ADB + APEX', 'cross-region DR', 'data lakehouse')"
- If the user picks **11**, switch to finding intake mode:
@@ -263,7 +286,7 @@ After delivering an output, show elapsed time and offer the natural next step.
```
✅ Done — [task description] completed in [Xm Ys]
→ [A] Generate additional outputs (drawio / doc / xlsx)
→ [A] Generate additional outputs (sketch / drawio / doc / xlsx)
→ [B] Modify the architecture (add/remove/change services)
→ [C] Run Well-Architected review on this architecture
→ [D] Build a business case from this architecture
@@ -466,7 +489,7 @@ Full DELIVER guide: [docs/deliver-phase.md](docs/deliver-phase.md)
Every skill option that produces output MUST generate **readable, human-consumable output** as the primary deliverable. YAML files are structured backing data — they are never the final output shown to the user. Specifically:
- **Options 1-4, 8:** Primary output is `.pptx` (slide deck) and/or `.drawio` (diagram). YAML specs are saved alongside but never presented as the deliverable.
- **Options 1-4, 8:** Primary output is `.pptx` (slide deck), `.drawio` (diagram), and/or native `.excalidraw` sketch when the user requests draft mode. YAML/Mermaid specs are saved alongside when useful but never presented as the final deliverable.
- **Option 5 (WA Review):** Primary output is the **formatted terminal scorecard** (banner + pillar bars + gap tables + recommendations). YAML scorecard saved to `examples/` as backing data.
- **Option 12 (ECAL Readiness):** Primary output is the **formatted terminal scorecard** (phase scores + artefact checklist + gap analysis). YAML scorecard saved to `examples/` as backing data.
- **After any review/score:** When the user picks [C] "Export as slide", generate a 1-2 slide `.pptx` with the scorecard visualization using `tools/oci_deck_gen.py`.

View File

@@ -339,8 +339,11 @@ Option 8 in the menu generates a business case deck for customer internal approv
| Cover | Dark - Title_Pillar | Customer name + subtitle |
| Executive Summary | Impact Statement | Bold 1-sentence opportunity |
| Business Drivers | Multi Statement | 3 key drivers: Why now |
| TCO Comparison | Blank + Table | Current vs OCI (3-5 year) |
| ROI Headline | Blank + Metric | Big number (e.g., "2080% ROI") |
| TCO Comparison | Blank + Table | Current/as-is run-rate vs target/to-be run-rate |
| BOM + Operations Breakdown | Blank + Tables | Cloud services, storage/infra, GoldenGate, operations, one-time bridge |
| Forecasted TCO | Blank + Table | 24M/36M annual run-rate snapshots, not cumulative totals |
| TCO Crossover | Native shapes | ADB-S vs ADB-D annual TCO and first cheaper period |
| Business Value Model | Blank + Table | Financial baseline, architecture benefit, risk-adjusted value, KPIs |
| Value Drivers | Blank + Cards | 4 categories: Cost, Risk, Agility, Innovation |
| Risk Assessment | Blank + 2-Column | Migration risks vs Do-nothing risks |
| Roadmap | Blank + Timeline | Implementation phases |
@@ -348,6 +351,12 @@ Option 8 in the menu generates a business case deck for customer internal approv
Uses the **Oracle FY26 official PowerPoint template** with Redwood design system.
For ADB-S to ADB-D cases, the generator keeps workload ECPU demand separate
from ADB-D physical capacity. Dedicated footprint is modeled as fixed DB server
and storage server infrastructure; projected BOMs are annual run-rate snapshots
at the horizon. Risk-reduction value is converted to USD only when the customer
provides business impact per degraded/outage hour.
```bash
python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case.pptx
```

View File

@@ -84,7 +84,7 @@ Present these options as a compact numbered list. Each option has a bold title f
DESIGN & PROPOSE
─────────────────
1. 📋 Full proposal — *notes → architecture + deck + native PPTX diagram + costs*
2. 📐 Architecture diagram — *description → .drawio or native PPTX*
2. 📐 Architecture diagram — *description/sketch → .excalidraw → .drawio or native PPTX*
3. 📊 Slide deck — *architecture → .pptx with native OCI diagram*
4. 💰 Cost estimate — *quick PAYG/BYOL comparison while you scope (terminal + optional deck)*
@@ -122,6 +122,7 @@ Pick a number, or just describe what you need.
If the user asks for examples, or skips the menu and writes a natural-language request, prompts like these map directly to the capabilities above:
- `Generame un diagrama nativo en .pptx, simple y prolijo, de esta arquitectura: usuarios externos -> load balancer público -> 2 VMs de aplicación en subnet privada -> Autonomous Database Serverless. Quiero que quede presentable para mostrar directo en PowerPoint. No me pidas YAML ni setup adicional.`
- `Sketch this architecture first for Excalidraw: external users -> public load balancer -> private app VMs -> Autonomous Database. Give me a native .excalidraw file so I can review and collaborate before making the formal diagram.`
- `Generate a simple high-level integration diagram for this flow: Slack -> Oracle Integration Cloud (OIC) -> Jira Service Management -> My Oracle Support. Add clear Client, Oracle, and External layers. Use brand icons for Slack and Jira Service Management, and deliver both .drawio and .pptx.`
- `Generate a high-level architecture diagram for this scenario: 1 PostgreSQL in OCI Ashburn, accessed from GCP Virginia through interconnect. PostgreSQL connects internally to 1 Autonomous Database Serverless, which also has 1 refreshable clone in the same region but in a different AD. Deliver both .drawio and .pptx. Keep it simple, executive-friendly, and technically correct.`
- `Gere um BOM em USD para este cenário: PostgreSQL no OCI com 4 OCPU e 500 GB, ADB-S com 200 ECPU e 1 TB BYOL, 1 refreshable clone com o mesmo sizing e FastConnect de 1 Gbps. Aplique 11% de desconto, 12 meses, 24 horas por dia. Quero o arquivo .xlsx e um resumo claro do custo mensal e anual.`
@@ -134,15 +135,29 @@ If the user asks for examples, or skips the menu and writes a natural-language r
```
1. Describe the architecture you want to diagram (or paste a YAML spec if you have one).
2. Which output format(s) do you want? Pick one or more:
(a) .drawio — editable technical diagram
(b) .pptx — native Oracle-style PowerPoint diagram/slide
(c) both
(a) sketch — native .excalidraw whiteboard draft for brainstorming
(b) .drawio — editable technical diagram
(c) .pptx — native Oracle-style PowerPoint diagram/slide
(d) sketch + .drawio
(e) sketch + .pptx
(f) all
```
After the user answers, **follow these steps in order — do NOT skip step 1**:
If the user chooses **sketch only**, create a concise sketch spec and run `python tools/oci_excalidraw_gen.py --spec <sketch-spec.yaml> --output <output-dir>/architecture-sketch.excalidraw`. Save the generated `.excalidraw` file and companion `.md` instructions in the engagement output folder. Present the file list and the simple import/share workflow from the companion `.md` as the primary deliverable. Optionally save `architecture-sketch.mmd` as a secondary source when Mermaid is useful, but the native `.excalidraw` file is the draft artifact. Do not run the formal `absolute_layout` renderer unless the user asks to promote the sketch to `.drawio` or `.pptx`.
If the user chooses **sketch plus formal output**, produce the `.excalidraw` draft first and ask the user to confirm or adjust the intent before formal rendering. If the user explicitly says to generate all outputs without another checkpoint, generate the sketch and continue. If the user later uploads or references an edited `.excalidraw` from Excalidraw.com, treat it as visual discovery/reference input for the formal `.drawio` / `.pptx` flow; do not blindly convert its rough geometry into the final source of truth.
If the user provides an existing `.excalidraw` file and asks to convert it to `.drawio`, default to **Formal OCI diagram** unless they explicitly ask for a literal/whiteboard copy:
- **Hard rule:** do **not** hand-roll draw.io XML with inline Python for this path. Use the repo tools below. If `python` is not available, use `.venv/bin/python` or `python3`; do not fall back to a custom converter.
- **Formal OCI diagram (default)** — run `.venv/bin/python tools/excalidraw_to_oci_diagram.py --input <file.excalidraw> --output <output-dir>/<name>-oci.drawio --spec-output <output-dir>/<name>-oci.yaml`. This extracts the approved sketch intent, writes `*-analysis.yaml` and `*-review.md`, generates an `absolute_layout` YAML, then renders a new Oracle-style `.drawio` through `oci_diagram_gen.py` with OCI containers, icons, connector styles, and validators. For complex sketches (multicloud, hub-spoke, migration/cutover, cross-region DR, multiple regions/VCNs), run the same command with `--mode review` first, inspect/refine the generated analysis/spec, then render.
- **Literal sketch conversion (secondary)** — run `.venv/bin/python tools/excalidraw_to_drawio.py --input <file.excalidraw> --output <output-dir>/<name>-literal.drawio` only when the user asks to preserve the exact whiteboard look. This is an editable bridge artifact and does **not** infer OCI icons/services.
- **Both** — produce the formal OCI `.drawio` first, then the literal bridge if useful for comparison.
For any `.drawio` or `.pptx` output, **follow these steps in order — do NOT skip step 1**:
1. **Reference-architecture lookup.** Run `python tools/archcenter_pattern_lookup.py "<topology keywords>"` against `kb/architecture-center/catalog.yaml` (123 Oracle-curated entries with cached `.drawio` / `_description.md` / `_template.yaml` under `kb/diagram/assets/archcenter-refs/`). Pick the highest-scoring entry whose topology matches.
- **The cached `_template.yaml` is your YAML SCAFFOLD** — auto-extracted from the canonical `.drawio` by `tools/archcenter_drawio_to_template.py`. It carries Oracle's container geometry (region/vcn/ad/subnet bboxes), service positions, and edge waypoints in the `absolute_layout:` shape your spec uses. Copy it as the starting point, rename ids, fill in `type:` for services (the extractor leaves `type: TODO_identify` because the canonical icon TYPE is encoded in stencil bytes — pick the right alias from the renderer's TYPE_TO_ICON), and adapt to the customer's components. Lookup result surfaces it as `cached: yaml=<path>`.
- **The cached `.drawio` is the visual source of truth** if the template extractor missed something — open it (drawio.exe, the SVG companion, or the XML) for the canonical look.
- **Migration/cutover + multicloud templates.** Also check `kb/diagram/templates/catalog.yaml`. Use `renderable_templates` when the user asks for an operational cutover/runbook view (for example ADB-S → ADB-D with OGG forward/reverse replication) instead of a canonical steady-state Architecture Center topology.
- **Do NOT use `examples/` as a geometry source.** `examples/` is previous user output, not authoritative. The `_template.yaml` covers everything `examples/` was being abused for.
- **Lookup budget: max 2 queries — never loop.** If query 1 returns nothing convincing, run ONE refinement (broader phrasing or different keyword angle), then STOP. Re-querying the catalog 3+ times to find a "better" match is the dominant time-sink in this phase — the catalog has 123 entries indexed once; if two passes can't surface a topology match, none exists. Output of the lookup tool also surfaces a **`⚠ KNOWN GAP`** banner at the top whenever the query touches a documented gap (e.g. OCI-native services accessed FROM GCP via Cross-Cloud Interconnect, or OCI Cache/Redis/PostgreSQL as primary subject). When that banner appears, follow its **Recommended composition** verbatim instead of running more queries — it lists the primitives to combine. When NO gap banner appears AND no top-3 entry matches the topology, present the closest 3 to the user with: *"No exact ref-arch matches. Closest hits are X / Y / Z. I can either base the diagram on the closest of these (geometry transfers, service names don't), or compose from `<list specific slugs / patterns>`. Which?"* — let the user pick the chassis instead of guessing.
2. **Pre-generation review.** Confirm the component list with the user (REQUESTED + TECHNICAL DEPENDENCIES per the whitelist).
@@ -185,14 +200,22 @@ If the user asks for examples, or skips the menu and writes a natural-language r
1. Parse input into `templates/business-case.yaml` structure
2. Identify business drivers and urgency from discovery notes
3. Estimate TCO comparison using `kb/pricing/oci-sku-catalog.yaml` (live SKU prices), `kb/pricing/compute.yaml` (shape-level), `kb/field-knowledge/pricing-knowledge.yaml` (billing models, BYOL rules, free tiers), and `kb/sizing/*`
4. Calculate ROI and payback period
5. Map value drivers (cost, risk, agility, innovation) with quantified evidence
6. Assess migration risks from `kb/field-knowledge/gotchas.yaml` and do-nothing risks
7. Compare with alternatives using `kb/competitive/*`
8. Generate implementation roadmap based on engagement tier
9. Produce a 8-10 slide deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), using a native OCI PowerPoint diagram when the architecture is structured enough
10. Output: business-case.pptx + business-case.yaml (reusable spec)
11. **Disclaimer + deck verification.** Append the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` as a final "Commercial Disclaimer" slide (verbatim — see option 4 step 7). Then run the same structural deck verification described in option 4 step 8 (slide count, titles, chart types, disclaimer-last) before reporting completion.
4. For ADB-S to ADB-D cases, use the reusable `adbs_to_adbd` model:
- keep workload/billable ECPU demand separate from dedicated physical capacity (`DB nodes × ECPU per DB node`)
- do not assume ADB-D uses 100% physical capacity; show utilization explicitly
- keep workload demand comparable between ADB-S and ADB-D unless customer data says otherwise
- model ADB-D fixed DB server + storage server infrastructure separately from workload ECPU
- keep ADB-S as-is components explicit: primaries, local ADG, cross-region standby, refreshable/read clones, read clones, GoldenGate
5. Separate financial views: current/as-is run-rate, target/to-be run-rate, forecasted TCO by horizon, and scenario comparison when applicable. Avoid ambiguous labels such as `OCI Annual`; use scenario labels like `ADB-S As-Is`, `ADB-D Dedicated`, `Incremental Investment`, and `TCO Crossover`.
6. Generate companion BOMs for current as-is, to-be steady state, and 24M/36M projected as-is/to-be annual run-rate snapshots. Projected BOMs are not cumulative multi-year totals.
7. Model read architecture explicitly: ADB-S may require refreshable/read clones because standby is not the application read path; ADB-D may use Local ADG read-only standby and retire steady-state clones. Reflect this in BOM notes, value drivers, and risk/value slides.
8. Add storage economics: ADB-S per-GB storage, ADB-D fixed footprint, customer-provided base break-even when available, recalculated break-even for the proposed footprint, and visible storage offset.
9. Treat GoldenGate as `steady_state`, `migration_bridge_only`, or `migration_plus_fallback_months`. Bridge-only GoldenGate is Year-1 / one-time and is excluded from future steady-state BOMs.
10. Add a TCO crossover chart when multi-year forecast exists, defaulting to native PowerPoint grouped bars with executive labels (`Near-term`, `Year 1`, `Year 2`, `Year 3`) and delta bullets.
11. For risk-reduction cases, generate a Business Value Model instead of generic ROI percentages. Do not invent revenue impact; convert stability to USD only when the customer provides revenue-at-risk, transaction margin, fraud loss impact, or cost per degraded/outage hour.
12. Assess migration and inaction risks against the selected scenario only. Remove obsolete risks such as `if 4x3 is selected` after the customer has chosen `5x3`.
13. Produce an executive deck using Oracle FY26 template (`config/oracle-pptx-layouts.yaml` → `business_case` type), plus business-case YAML and generated BOM artefacts where applicable.
14. **Disclaimer + deck verification.** Append the `disclaimer:` field from `kb/pricing/oci-sku-catalog.yaml` as a final "Commercial Disclaimer" slide (verbatim — see option 4 step 7). Then run structural PPTX validation that reads text boxes, table cells, and grouped shapes; check slide count/titles, disclaimer-last, required assumptions, and forbidden obsolete phrases before reporting completion.
- If the user picks **9**, ask: "What topic? (e.g., 'DEP', 'TAC', 'maintenance window', 'vector search')"
- If the user picks **10**, ask: "What kind of architecture? (e.g., 'ADB + APEX', 'cross-region DR', 'data lakehouse')"
- If the user picks **11**, switch to finding intake mode:
@@ -261,7 +284,7 @@ After delivering an output, show elapsed time and offer the natural next step.
```
✅ Done — [task description] completed in [Xm Ys]
→ [A] Generate additional outputs (drawio / doc / xlsx)
→ [A] Generate additional outputs (sketch / drawio / doc / xlsx)
→ [B] Modify the architecture (add/remove/change services)
→ [C] Run Well-Architected review on this architecture
→ [D] Build a business case from this architecture
@@ -464,7 +487,7 @@ Full DELIVER guide: [docs/deliver-phase.md](docs/deliver-phase.md)
Every skill option that produces output MUST generate **readable, human-consumable output** as the primary deliverable. YAML files are structured backing data — they are never the final output shown to the user. Specifically:
- **Options 1-4, 8:** Primary output is `.pptx` (slide deck) and/or `.drawio` (diagram). YAML specs are saved alongside but never presented as the deliverable.
- **Options 1-4, 8:** Primary output is `.pptx` (slide deck), `.drawio` (diagram), and/or native `.excalidraw` sketch when the user requests draft mode. YAML/Mermaid specs are saved alongside when useful but never presented as the final deliverable.
- **Option 5 (WA Review):** Primary output is the **formatted terminal scorecard** (banner + pillar bars + gap tables + recommendations). YAML scorecard saved to `examples/` as backing data.
- **Option 12 (ECAL Readiness):** Primary output is the **formatted terminal scorecard** (phase scores + artefact checklist + gap analysis). YAML scorecard saved to `examples/` as backing data.
- **After any review/score:** When the user picks [C] "Export as slide", generate a 1-2 slide `.pptx` with the scorecard visualization using `tools/oci_deck_gen.py`.

View File

@@ -0,0 +1,63 @@
# Business Case Flow
Option 8 produces an executive business-case deck plus reusable YAML backing
data. For ADB-S to ADB-D cases, use `business_case.adbs_to_adbd`.
## ADB-S to ADB-D Rules
- Keep workload/billable ECPU demand comparable between ADB-S and ADB-D unless
the customer provides a different target demand.
- Dedicated physical capacity is a separate fixed footprint:
`DB nodes x ECPU per DB node`.
- Never model ADB-D at 100% utilization by default. Show utilization explicitly.
- ADB-D fixed infrastructure includes DB servers and storage servers; workload
ECPU demand remains a separate line.
- Projected BOMs are annual run-rate snapshots at the horizon, not cumulative
multi-year totals.
- GoldenGate can be `steady_state`, `migration_bridge_only`, or
`migration_plus_fallback_months`. Bridge-only GoldenGate is excluded from
future steady-state BOMs.
## Read Architecture
ADB-S and ADB-D can carry the same workload demand while using different read
architectures. ADB-S may require refreshable/read clones when standby is not the
application read path. ADB-D may use Local ADG read-only standby and retire
steady-state clones. The deck should call this out in BOM notes, value drivers,
and risk/value slides.
## Storage Economics
Storage economics are shown separately from aggregate TCO:
```text
break-even TB = fixed dedicated infra monthly cost / ADB-S storage USD per GB-month / 1024
```
Apply discounts uniformly when comparing scenarios. If customer material
provides a baseline break-even, show it beside the recalculated break-even for
the proposed footprint.
## Business Value
Risk-reduction cases use a Business Value Model instead of generic ROI
percentages. Do not invent revenue impact. Convert stability to USD only when
the customer provides revenue-at-risk, transaction margin, fraud loss impact, or
cost per degraded/outage hour.
Use:
```text
avoided degraded/outage hours x business impact per hour
```
Track success metrics such as CPU utilization, read latency, clone lag retired,
ADG read lag, GoldenGate/apply lag during migration, incidents avoided, and
degraded hours avoided.
## Validation
Business-case deck validation reads text boxes, table cells, and grouped shapes.
It checks expected titles, disclaimer-last, required assumptions, and forbidden
obsolete phrases such as `OCI Annual`, stale scenario risks, and unclear
language like `FTE-year`.

View File

@@ -0,0 +1,89 @@
business_case:
customer_name: "Synthetic Commerce Co."
cover_subtitle: "ADB-S to ADB-D Migration Business Case"
prepared_by: "Diego Cabrera"
prepared_by_role: "Solutions Architect"
date: "2026-05-08"
executive_summary: >
Synthetic Commerce Co. is evaluating a move from ADB-S to ADB-D for a
fraud analytics workload where predictable performance, read-path cleanup,
and storage economics are more important than a pure near-term cost takeout.
drivers:
primary: "Stabilize fraud analytics performance while keeping the business case auditable."
urgency: "Validate capacity, TCO crossover, and migration bridge assumptions before procurement."
adbs_to_adbd:
license_model: BYOL
discount_pct: 0.11
ecpu_per_db_node: 760
current:
label: "ADB-S As-Is"
workload_ecpu: 2400
storage_tb: 96
read_architecture: "Refreshable/read clones serve application reads; standby is not the application read path."
target:
label: "ADB-D Dedicated"
workload_ecpu: 2400
storage_tb: 96
db_nodes: 5
storage_servers: 3
read_architecture: "Local ADG read-only standby serves application reads; steady-state clones retired."
forecasts:
- period: "24M"
display_label: "Year 2"
as_is:
workload_ecpu: 3150
storage_tb: 128
to_be:
workload_ecpu: 3150
storage_tb: 128
db_nodes: 6
storage_servers: 3
- period: "36M"
display_label: "Year 3"
as_is:
workload_ecpu: 3900
storage_tb: 166
to_be:
workload_ecpu: 3900
storage_tb: 166
db_nodes: 8
storage_servers: 3
goldengate:
mode: migration_bridge_only
ocpus: 4
bridge_months: 3
bridge_one_time_cost: 0
storage_economics:
base_break_even_tb: 91
business_impact:
business_impact_per_hour: 0
crossover_chart:
y_axis_min: 1.5
y_axis_max: 3.5
y_axis_major_unit: 0.5
roadmap:
total_duration: "12 weeks"
phases:
- name: "Define"
duration: "2 weeks"
deliverables: ["Baseline current BOM", "Confirm success metrics"]
- name: "Design"
duration: "4 weeks"
deliverables: ["Dedicated capacity plan", "Read-path validation"]
- name: "Deliver"
duration: "6 weeks"
deliverables: ["Migration bridge", "Cutover rehearsal"]
recommendation:
summary: "Proceed with detailed design for the ADB-D Dedicated target, validating the utilization, storage break-even, and GoldenGate bridge assumptions before final commercial approval."
next_steps:
- action: "Confirm workload ECPU demand and storage forecast"
owner: "Customer"
deadline: "Before design sign-off"
- action: "Validate ADB-D DB node and storage server reservation"
owner: "Oracle"
deadline: "Before procurement"
deck_validation:
forbidden_phrases:
- "OCI Annual"
- "FTE-year"
- "if 4x3 is selected"

View File

@@ -1,4 +1,4 @@
last_verified: '2026-03-27'
last_verified: '2026-07-01'
source: Oracle OCI Price List + internal BOM reference v1.73
description: 'OCI SKU catalog for BOM generation. ~160 SKUs across 14+ categories. Prices are approximate and for estimation
only. Always verify against https://www.oracle.com/cloud/pricing/
@@ -564,7 +564,7 @@ categories:
- sku: B95714
product: Oracle Autonomous AI Transaction Processing - Dedicated - ECPU
metric: ECPU Per Hour
list_price_usd: 0.336
list_price_usd: 0.0807
default_hours_units: 730
billing_type: hourly
- sku: B95715
@@ -1302,12 +1302,6 @@ categories:
list_price_usd: 0.015
default_hours_units: 730
billing_type: hourly
# OCI Data Science and Data Flow do not have dedicated SKUs in the Oracle
# public pricing API — they are billed via the underlying Compute + Block/Object
# Storage consumed by notebook sessions, model-deployment endpoints, and Spark
# runs. The entries below are placeholder estimates (VM.Standard3.Flex OCPU rate)
# so BOMs can include them as line items; always confirm against the customer's
# actual shape + usage before quoting.
- sku: EST-DS-NOTEBOOK
product: '[ESTIMATE] OCI Data Science - Notebook Session (billed via underlying VM.Standard compute OCPU)'
metric: OCPU Per Hour

View File

@@ -7,6 +7,8 @@ business_case:
# --- Header ---
customer_name: ""
prepared_by: ""
prepared_by_role: "" # e.g., "Solutions Architect"
cover_subtitle: "" # e.g., "ADB-S to ADB-D Migration Business Case"
date: ""
version: "1.0"
status: draft # draft | review | approved
@@ -36,6 +38,66 @@ business_case:
annual_spend: 0 # Current total annual IT spend for in-scope workloads
team_size: 0 # FTEs dedicated to operations
# --- Reusable ADB-S to ADB-D model (optional) ---
# Use this for dedicated migration business cases. The model keeps workload
# demand comparable while showing dedicated physical capacity separately.
adbs_to_adbd:
license_model: BYOL # BYOL | PAYG
discount_pct: 0 # 0.11 or 11 are both accepted
ecpu_per_db_node: 760
current:
label: "ADB-S As-Is"
workload_ecpu: 0 # billable/planning ECPU demand
storage_tb: 0
read_architecture: "refreshable/read clones for application reads"
components: [] # optional extra BOM lines with sku/qty/label/note
target:
label: "ADB-D Dedicated"
workload_ecpu: 0 # keep equal to current for fair comparison unless customer states otherwise
storage_tb: 0
db_nodes: 0 # physical footprint
storage_servers: 0 # fixed infra/storage footprint
read_architecture: "Local ADG read-only standby for application reads"
forecasts:
- period: "24M"
display_label: "Year 2"
as_is:
workload_ecpu: 0
storage_tb: 0
to_be:
workload_ecpu: 0
storage_tb: 0
db_nodes: 0
storage_servers: 0
- period: "36M"
display_label: "Year 3"
as_is:
workload_ecpu: 0
storage_tb: 0
to_be:
workload_ecpu: 0
storage_tb: 0
db_nodes: 0
storage_servers: 0
goldengate:
mode: migration_bridge_only # steady_state | migration_bridge_only | migration_plus_fallback_months
ocpus: 0
bridge_months: 0
storage_economics:
adbs_storage_usd_per_gb_month: 0 # optional override; otherwise SKU catalog B95706
base_break_even_tb: "" # optional customer-provided baseline
business_impact:
# Stability is converted to USD only when customer provides one of these.
business_impact_per_hour: 0
revenue_at_risk_per_hour: 0
transaction_margin_per_hour: 0
fraud_loss_impact_per_hour: 0
cost_per_outage_hour: 0
crossover_chart:
y_axis_min: null
y_axis_max: null
y_axis_major_unit: 0.5
# --- TCO Comparison ---
tco:
horizon_years: 3 # 3 or 5

View File

@@ -100,3 +100,43 @@ def test_from_spec_enriches_sparse_business_case_from_executive_summary():
assert "Implementation Roadmap" in text
assert "Our Recommendation" in text
assert "12 weeks" in text
def test_from_spec_enriches_adbs_to_adbd_business_case_without_meli_hardcoding():
spec = {
"customer_name": "Synthetic Retail",
"cover_subtitle": "ADB-S to ADB-D Migration Business Case",
"prepared_by": "Diego Cabrera",
"prepared_by_role": "Solutions Architect",
"executive_summary": "Move a fraud analytics workload from ADB-S to ADB-D for stability.",
"adbs_to_adbd": {
"license_model": "BYOL",
"discount_pct": 0.11,
"ecpu_per_db_node": 760,
"current": {"label": "ADB-S As-Is", "workload_ecpu": 1000, "storage_tb": 80},
"target": {"label": "ADB-D Dedicated", "workload_ecpu": 1000, "storage_tb": 80, "db_nodes": 2, "storage_servers": 3},
"forecasts": [
{
"period": "24M",
"display_label": "Year 2",
"as_is": {"workload_ecpu": 1300, "storage_tb": 120},
"to_be": {"workload_ecpu": 1300, "storage_tb": 120, "db_nodes": 3, "storage_servers": 3},
}
],
"goldengate": {"mode": "migration_bridge_only", "ocpus": 2, "bridge_months": 3},
},
}
gen = BusinessCaseDeckGenerator.from_spec(spec)
text = _collect_slide_text(gen)
assert "Prepared by: Diego Cabrera, Solutions Architect" in text
assert "ADB-S As-Is" in text
assert "ADB-D Dedicated" in text
assert "BOM + Operations Cost Breakdown" in text
assert "TCO Crossover" in text
assert "Business Value Model" in text
assert "Workload ECPU demand" in text
assert "ECPU capacity" in text
assert "No invented revenue" in text
assert "MELI" not in text

View File

@@ -0,0 +1,123 @@
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TOOLS_DIR = PROJECT_ROOT / "tools"
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from oci_business_case_model import ( # noqa: E402
SKU_ADBD_DB_SERVER,
SKU_ADBD_STORAGE_SERVER,
SKU_ADBS_STORAGE_ATP,
build_bom_specs,
build_crossover_chart,
build_storage_economics,
build_tco_projection,
capacity_model,
first_crossover_period,
storage_break_even_tb,
)
def _scenario():
return {
"customer_name": "Synthetic Retail",
"prepared_by": "Diego Cabrera",
"license_model": "BYOL",
"discount_pct": 0.10,
"ecpu_per_db_node": 760,
"current": {
"label": "ADB-S As-Is",
"workload_ecpu": 1000,
"storage_tb": 80,
},
"target": {
"label": "ADB-D Dedicated",
"workload_ecpu": 1000,
"storage_tb": 80,
"db_nodes": 2,
"storage_servers": 3,
},
"forecasts": [
{
"period": "24M",
"display_label": "Year 2",
"as_is": {"workload_ecpu": 1300, "storage_tb": 120},
"to_be": {"workload_ecpu": 1300, "storage_tb": 120, "db_nodes": 3, "storage_servers": 3},
},
{
"period": "36M",
"display_label": "Year 3",
"as_is": {"workload_ecpu": 1600, "storage_tb": 160},
"to_be": {"workload_ecpu": 1600, "storage_tb": 160, "db_nodes": 4, "storage_servers": 3},
},
],
"goldengate": {"mode": "migration_bridge_only", "ocpus": 2, "bridge_months": 3},
}
def test_capacity_model_separates_workload_demand_from_physical_capacity():
cap = capacity_model({"workload_ecpu": 3705, "db_nodes": 7}, 760)
assert cap.physical_capacity_ecpu == 5320
assert round(cap.utilization_pct) == 70
assert "3,705 ECPU used over 5,320 ECPU capacity" in cap.label
def test_storage_break_even_calculation_applies_uniform_discount():
tb = storage_break_even_tb(14500, 0.1156, 0.10)
assert round(tb) == round(14500 / (0.1156 * 0.90) / 1024)
def test_build_bom_specs_generates_current_and_projected_run_rate_boms():
specs = build_bom_specs(_scenario())
assert "current_as_is" in specs
assert "to_be_steady_state" in specs
assert "as_is_projected_24m" in specs
assert "to_be_projected_36m" in specs
assert "migration_bridge_year1" in specs
current_skus = {item["sku"] for item in specs["current_as_is"]["bom"]["line_items"]}
target_skus = {item["sku"] for item in specs["to_be_steady_state"]["bom"]["line_items"]}
future_target_skus = {item["sku"] for item in specs["to_be_projected_36m"]["bom"]["line_items"]}
bridge_skus = {item["sku"] for item in specs["migration_bridge_year1"]["bom"]["line_items"]}
assert SKU_ADBS_STORAGE_ATP in current_skus
assert SKU_ADBD_DB_SERVER in target_skus
assert SKU_ADBD_STORAGE_SERVER in target_skus
assert future_target_skus == target_skus
assert "B92993" in bridge_skus
assert "B92993" not in future_target_skus
assert any("annual run-rate snapshots" in note for note in specs["to_be_projected_36m"]["bom"]["notes"])
def test_forecast_projection_and_crossover_chart_are_native_shape_ready():
projection = build_tco_projection(_scenario())
chart = build_crossover_chart(projection, _scenario())
assert projection[0]["label"] == "Near-term"
assert chart["categories"] == ["Near-term", "Year 2", "Year 3"]
assert chart["note"].startswith("Bars are native PowerPoint shapes")
assert chart["bullets"][0].startswith("Near-term: ADB-D")
def test_storage_economics_exposes_break_even_and_offset():
economics = build_storage_economics(_scenario())
assert economics["break_even_tb"] > 0
assert economics["storage_offset_monthly"] > 0
assert any(card["label"] == "Storage offset" for card in economics["cards"])
def test_first_crossover_period_detects_first_cheaper_target():
rows = [
{"label": "Near-term", "as_is_annual_tco": 100, "to_be_annual_tco": 110},
{"label": "Year 1", "as_is_annual_tco": 120, "to_be_annual_tco": 115},
]
assert first_crossover_period(rows) == "Year 1"

View File

@@ -0,0 +1,60 @@
import sys
from pathlib import Path
from pptx import Presentation
from pptx.util import Inches
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TOOLS_DIR = PROJECT_ROOT / "tools"
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from pptx_validator import validate_pptx # noqa: E402
def test_validate_pptx_reads_text_boxes_tables_and_flags_forbidden(tmp_path):
pptx = tmp_path / "validation-fixture.pptx"
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(4), Inches(0.4)).text = "TCO Crossover"
table = slide.shapes.add_table(1, 1, Inches(0.5), Inches(1.0), Inches(4), Inches(0.5)).table
table.cell(0, 0).text = "Storage break-even"
slide.shapes.add_textbox(Inches(0.5), Inches(1.7), Inches(4), Inches(0.4)).text = "OCI Annual"
last = prs.slides.add_slide(prs.slide_layouts[6])
last.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(4), Inches(0.4)).text = "Commercial Disclaimer"
prs.save(pptx)
report = validate_pptx(
pptx,
expected_titles=["TCO Crossover"],
required_phrases=["Storage break-even"],
forbidden_phrases=["OCI Annual"],
disclaimer_last=True,
)
assert report["status"] == "fail"
assert any(issue["code"] == "FORBIDDEN_PHRASE" for issue in report["issues"])
def test_validate_pptx_passes_required_and_disclaimer_last(tmp_path):
pptx = tmp_path / "validation-pass.pptx"
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
slide.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(7), Inches(0.4)).text = (
"BYOL/PAYG model Discount GoldenGate bridge duration "
"Workload ECPU demand ECPU capacity Storage break-even Crossover"
)
last = prs.slides.add_slide(prs.slide_layouts[6])
last.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(4), Inches(0.4)).text = "Commercial Disclaimer"
prs.save(pptx)
report = validate_pptx(
pptx,
required_phrases=["BYOL/PAYG model", "Storage break-even"],
forbidden_phrases=["FTE-year"],
disclaimer_last=True,
)
assert report["status"] == "pass"

View File

@@ -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()

View 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
View 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())