2 Commits

Author SHA1 Message Date
root
fabd7254dd Add SA Tools: BOM generator and AppCA export
- New tools/oci_bom_gen.py: generates .xlsx BOM from YAML spec with
    Oracle Redwood styling, Excel formulas, and Oracle disclaimer.
    Supports standard BOM (--output) and AppCA import format (--appca)
  - New tools/refresh_sku_catalog.py: refreshes SKU catalog prices from
    Oracle's public pricing API (apexapps.oracle.com)
  - New kb/pricing/oci-sku-catalog.yaml: ~160 OCI SKUs across 14
    categories, validated against Oracle API
  - New templates/bom-spec.yaml: input spec template for BOM generation
  - Add SA TOOLS section to SKILL.md (options 13-14)
  - Register BOM generator in oci_output.py
2026-03-27 15:21:50 -03:00
root
2b923f1aed Add SA Tools: BOM generator and AppCA export
- New tools/oci_bom_gen.py: generates .xlsx BOM from YAML spec with
    Oracle Redwood styling, Excel formulas, and Oracle disclaimer.
    Supports standard BOM (--output) and AppCA import format (--appca)
  - New tools/refresh_sku_catalog.py: refreshes SKU catalog prices from
    Oracle's public pricing API (apexapps.oracle.com)
  - New kb/pricing/oci-sku-catalog.yaml: ~160 OCI SKUs across 14
    categories, validated against Oracle API
  - New templates/bom-spec.yaml: input spec template for BOM generation
  - Add SA TOOLS section to SKILL.md (options 13-14)
  - Register BOM generator in oci_output.py
2026-03-27 15:19:26 -03:00
10 changed files with 2536 additions and 2 deletions

View File

@@ -59,6 +59,11 @@ Present these options as a compact numbered list. Each option has a bold title f
───────────────── ─────────────────
12. 📊 ECAL readiness score — *60-artefact gap analysis* 12. 📊 ECAL readiness score — *60-artefact gap analysis*
SA TOOLS
─────────────────
13. 📦 BOM generator — *services + quantities → .xlsx Bill of Materials*
14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA*
━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━
Pick a number, or just describe what you need. Pick a number, or just describe what you need.
``` ```
@@ -247,6 +252,29 @@ Pick a number, or just describe what you need.
→ [D] Re-score after updates → [D] Re-score after updates
``` ```
- If the user picks **13**, ask: "What services does the customer need? (e.g., 'ExaCS X11M BYOL 2 DB servers + 4 storage + 128 ECPUs + ADB-S 8 ECPU + 2TB Block Storage + FastConnect 1Gbps'). I'll generate the BOM with only those SKUs."
Then follow the BOM generation flow:
1. Parse the customer request to identify needed OCI services and quantities
2. Match services against `kb/pricing/oci-sku-catalog.yaml` to select exact SKUs
3. Ask for discount % and contract duration if not specified (default: 0%, 12 months)
4. Ask if currency conversion is needed (e.g., USD→BRL with exchange rate and tax)
5. Generate the BOM spec YAML and save to the output folder
6. Run `tools/oci_bom_gen.py` to produce the .xlsx
7. Present a summary table in the terminal showing key totals (monthly, ARR)
8. List the files generated
**BOM Output Rules:**
- NEVER include "Confidential: Internal ONLY" or any confidentiality marking
- ALWAYS include the Oracle Cost Estimator disclaimer at the bottom
- Only include SKUs the customer actually requested — never dump the full catalog
- Show cost proportions so the customer can see where their spend concentrates
- Use Excel formulas (not static values) so the customer can adjust quantities
- If the user picks **14**, follow the same flow as option 13 (BOM generator) but generate the output in AppCA import format. AppCA is Oracle's internal deal approval tool. The generated .xlsx has two sheets:
- **"Export to AppCA"**: Flat table with columns SKU, QTY, HOURS, MONTHS, DISCOUNT, BURSTABLE — ready to paste/import into AppCA
- **"BOM.C1"**: Full BOM detail with product names, metrics, prices, and formulas for cost calculations
Run with: `python tools/oci_bom_gen.py --spec <spec>.yaml --output <name>.xlsx --appca`
- If the user sends discovery notes directly (without picking a number), detect this and go straight to option 1 (full proposal flow). - If the user sends discovery notes directly (without picking a number), detect this and go straight to option 1 (full proposal flow).
- If the user asks a specific question (e.g., "does ADB-S support vector search?"), detect this and go straight to the relevant capability without showing the menu. - If the user asks a specific question (e.g., "does ADB-S support vector search?"), detect this and go straight to the relevant capability without showing the menu.
- Only show the welcome menu on the FIRST message if it's a greeting or empty context. Don't re-show it on every turn. - Only show the welcome menu on the FIRST message if it's a greeting or empty context. Don't re-show it on every turn.

View File

@@ -60,7 +60,21 @@
"Bash(PYTHONPATH=/tmp/pptx_ok python3 -c \"from pptx import Presentation; print\\(''''OK''''\\)\")", "Bash(PYTHONPATH=/tmp/pptx_ok python3 -c \"from pptx import Presentation; print\\(''''OK''''\\)\")",
"Bash(PYTHONPATH=/tmp/pptx_ok python3 /tmp/migrate_to_oracle.py)", "Bash(PYTHONPATH=/tmp/pptx_ok python3 /tmp/migrate_to_oracle.py)",
"Bash(PYTHONPATH=/tmp/pptx_ok python3 -c \"\nfrom pptx import Presentation\nprs = Presentation\\('/mnt/c/Users/Diego/Documents/oci-deal-accelerator/oci-deal-accelerator/templates/Oracle_PPT-template_FY26.pptx'\\)\nprint\\('Template has {} existing slides'.format\\(len\\(prs.slides\\)\\)\\)\n\")", "Bash(PYTHONPATH=/tmp/pptx_ok python3 -c \"\nfrom pptx import Presentation\nprs = Presentation\\('/mnt/c/Users/Diego/Documents/oci-deal-accelerator/oci-deal-accelerator/templates/Oracle_PPT-template_FY26.pptx'\\)\nprint\\('Template has {} existing slides'.format\\(len\\(prs.slides\\)\\)\\)\n\")",
"Bash(grep:*)" "Bash(grep:*)",
"Bash(ls \"/mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/templates/Oracle_PPT\"*)",
"Bash(ls \"/mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/templates/\"*.pptx)",
"Bash(ls \"/mnt/c/Users/Diego/Documents/oci-deal-accelerator-oracle/oci-deal-accelerator/templates/\"*.potx)",
"Bash(python3.12 -c \"import drawpyo\")",
"Bash(python3.12 tools/oci_diagram_gen.py --spec examples/output-meli-kvs/diagram-spec.yaml --output examples/output-meli-kvs/architecture.drawio)",
"Bash(python3.12 tools/oci_pdf_gen.py --spec examples/output-meli-kvs/proposal-spec.yaml --output examples/output-meli-kvs/customer-proposal.pdf)",
"Bash(python3.12 -c \":*)",
"Bash(python3.12 tools/oci_deck_gen.py --spec examples/output-meli-kvs/proposal-spec.yaml --output examples/output-meli-kvs/architecture-proposal.pptx)",
"Bash(python3.12 tools/oci_bizcase_gen.py --spec examples/output-meli-kvs/business-case.yaml --output examples/output-meli-kvs/business-case.pptx)",
"Bash(python3 -c \":*)",
"WebFetch(domain:github.com)",
"Bash(curl -s --max-time 15 -H \"X-Oracle-Accept-CurrencyCode: USD\" \"https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=2&partNumber=B110627\")",
"Bash(curl -v --max-time 10 -H \"X-Oracle-Accept-CurrencyCode: USD\" \"https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=1\")",
"Bash(curl -s --max-time 15 \"https://apexapps.oracle.com/pls/apex/cetools/api/v1/products/?partNumber=B110627&currencyCode=USD\")"
] ]
} }
} }

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ examples/*.yaml
# Exported PDFfiles # Exported PDFfiles
examples/*.pdf examples/*.pdf
examples/*.xlsx

View File

@@ -38,6 +38,7 @@ AI skill aligned with Oracle ECAL framework (Define → Design → Deliver) that
│ │ └── (dirs: database-ha/, database-dr/, networking-hub-spoke/, etc.) │ │ └── (dirs: database-ha/, database-dr/, networking-hub-spoke/, etc.)
│ ├── sizing/ # CPU conversion ratios, IOPS, scaling rules │ ├── sizing/ # CPU conversion ratios, IOPS, scaling rules
│ ├── pricing/ # Simplified pricing for estimation │ ├── pricing/ # Simplified pricing for estimation
│ │ └── oci-sku-catalog.yaml # ~160 OCI SKUs for BOM generation
│ ├── competitive/ # AWS/Azure/GCP service mapping │ ├── competitive/ # AWS/Azure/GCP service mapping
│ ├── well-architected/ # 5-pillar WA Framework checklists │ ├── well-architected/ # 5-pillar WA Framework checklists
│ ├── compatibility/ # Feature matrices (ADB, etc.) │ ├── compatibility/ # Feature matrices (ADB, etc.)
@@ -47,7 +48,9 @@ AI skill aligned with Oracle ECAL framework (Define → Design → Deliver) that
│ ├── oci_deck_gen.py # .pptx slide deck generator (DEFAULT output) │ ├── oci_deck_gen.py # .pptx slide deck generator (DEFAULT output)
│ ├── oci_pdf_gen.py # .pdf customer-facing document (branded, no internal refs) │ ├── oci_pdf_gen.py # .pdf customer-facing document (branded, no internal refs)
│ ├── oci_diagram_gen.py # .drawio diagram generator │ ├── oci_diagram_gen.py # .drawio diagram generator
│ ├── oci_bom_gen.py # .xlsx Bill of Materials generator (SA tool)
│ ├── oci_output.py # Output orchestrator │ ├── oci_output.py # Output orchestrator
│ ├── refresh_sku_catalog.py # SKU catalog refresh from Oracle pricing API
│ └── refresh_arch_catalog.py # Architecture Center catalog refresh tool │ └── refresh_arch_catalog.py # Architecture Center catalog refresh tool
├── scripts/ # Validation and utilities ├── scripts/ # Validation and utilities
│ └── validate-architecture.py # WA validation engine │ └── validate-architecture.py # WA validation engine
@@ -72,7 +75,8 @@ AI skill aligned with Oracle ECAL framework (Define → Design → Deliver) that
│ ├── handover-document.yaml # DELIVER: Implementation handover │ ├── handover-document.yaml # DELIVER: Implementation handover
│ ├── go-live-checklist.yaml # DELIVER: Pre-cutover verification │ ├── go-live-checklist.yaml # DELIVER: Pre-cutover verification
│ ├── success-criteria.yaml # DELIVER: Post go-live metrics │ ├── success-criteria.yaml # DELIVER: Post go-live metrics
── lessons-learned.yaml # DELIVER: Engagement retrospective ── lessons-learned.yaml # DELIVER: Engagement retrospective
│ └── bom-spec.yaml # SA TOOL: BOM input spec template
├── codex/ # Codex setup guide (README only) ├── codex/ # Codex setup guide (README only)
└── examples/ # Example specs and generated outputs └── examples/ # Example specs and generated outputs
``` ```
@@ -115,6 +119,9 @@ python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposa
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf --diagram arch.png python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf --diagram arch.png
# Generate BOM (.xlsx Bill of Materials)
python tools/oci_bom_gen.py --spec examples/bom-spec.yaml --output customer-bom.xlsx
# Generate diagram # Generate diagram
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
@@ -127,6 +134,12 @@ python scripts/validate-architecture.py \
# Output orchestrator (multiple formats at once) # Output orchestrator (multiple formats at once)
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/ python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
# Refresh SKU catalog from Oracle pricing API
python tools/refresh_sku_catalog.py --refresh # update all prices
python tools/refresh_sku_catalog.py --refresh --diff # update + show changes
python tools/refresh_sku_catalog.py --validate # check for stale prices
python tools/refresh_sku_catalog.py --sku B110627 # inspect single SKU
# Refresh Architecture Center catalog # Refresh Architecture Center catalog
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
python tools/refresh_arch_catalog.py --url <url> # add single entry python tools/refresh_arch_catalog.py --url <url> # add single entry

View File

@@ -59,6 +59,11 @@ Present these options as a compact numbered list. Each option has a bold title f
───────────────── ─────────────────
12. 📊 ECAL readiness score — *60-artefact gap analysis* 12. 📊 ECAL readiness score — *60-artefact gap analysis*
SA TOOLS
─────────────────
13. 📦 BOM generator — *services + quantities → .xlsx Bill of Materials*
14. 📤 BOM for AppCA — *BOM → .xlsx ready to import into AppCA*
━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━
Pick a number, or just describe what you need. Pick a number, or just describe what you need.
``` ```
@@ -264,6 +269,29 @@ Pick a number, or just describe what you need.
→ [D] Re-score after updates → [D] Re-score after updates
``` ```
- If the user picks **13**, ask: "What services does the customer need? (e.g., 'ExaCS X11M BYOL 2 DB servers + 4 storage + 128 ECPUs + ADB-S 8 ECPU + 2TB Block Storage + FastConnect 1Gbps'). I'll generate the BOM with only those SKUs."
Then follow the BOM generation flow:
1. Parse the customer request to identify needed OCI services and quantities
2. Match services against `kb/pricing/oci-sku-catalog.yaml` to select exact SKUs
3. Ask for discount % and contract duration if not specified (default: 0%, 12 months)
4. Ask if currency conversion is needed (e.g., USD→BRL with exchange rate and tax)
5. Generate the BOM spec YAML and save to the output folder
6. Run `tools/oci_bom_gen.py` to produce the .xlsx
7. Present a summary table in the terminal showing key totals (monthly, ARR)
8. List the files generated
**BOM Output Rules:**
- NEVER include "Confidential: Internal ONLY" or any confidentiality marking
- ALWAYS include the Oracle Cost Estimator disclaimer at the bottom
- Only include SKUs the customer actually requested — never dump the full catalog
- Show cost proportions so the customer can see where their spend concentrates
- Use Excel formulas (not static values) so the customer can adjust quantities
- If the user picks **14**, follow the same flow as option 13 (BOM generator) but generate the output in AppCA import format. AppCA is Oracle's internal deal approval tool. The generated .xlsx has two sheets:
- **"Export to AppCA"**: Flat table with columns SKU, QTY, HOURS, MONTHS, DISCOUNT, BURSTABLE — ready to paste/import into AppCA
- **"BOM.C1"**: Full BOM detail with product names, metrics, prices, and formulas for cost calculations
Run with: `python tools/oci_bom_gen.py --spec <spec>.yaml --output <name>.xlsx --appca`
- If the user sends discovery notes directly (without picking a number), detect this and go straight to option 1 (full proposal flow). - If the user sends discovery notes directly (without picking a number), detect this and go straight to option 1 (full proposal flow).
- If the user asks a specific question (e.g., "does ADB-S support vector search?"), detect this and go straight to the relevant capability without showing the menu. - If the user asks a specific question (e.g., "does ADB-S support vector search?"), detect this and go straight to the relevant capability without showing the menu.
- Only show the welcome menu on the FIRST message if it's a greeting or empty context. Don't re-show it on every turn. - Only show the welcome menu on the FIRST message if it's a greeting or empty context. Don't re-show it on every turn.

File diff suppressed because it is too large Load Diff

50
templates/bom-spec.yaml Normal file
View File

@@ -0,0 +1,50 @@
# ──────────────────────────────────────────────────────────────────
# OCI Bill of Materials (BOM) — Spec Template
# ──────────────────────────────────────────────────────────────────
# The AI generates this YAML based on the customer's request,
# selecting only the relevant SKUs from kb/pricing/oci-sku-catalog.yaml.
# The BOM generator tool (tools/oci_bom_gen.py) renders it to .xlsx.
#
# Usage:
# python tools/oci_bom_gen.py --spec this-file.yaml --output bom.xlsx
bom:
# ── Header ──────────────────────────────────────────────────────
customer_name: ""
project_name: ""
prepared_by: ""
date: "" # ISO format: 2026-03-27
reference_label: "" # e.g., "ACME-BOM-001"
currency: USD # Primary currency
# realm: "OC1 - Commercial" # Optional: OC1 / OC2 / OC3 / OC9
# service_type: "Pay as You Go" # Optional: PAYG / UCM / Annual Flex
# ── Optional currency conversion ────────────────────────────────
# Enable to add converted columns (e.g., USD → BRL with tax)
conversion:
enabled: false
target_currency: BRL
exchange_rate: 5.75 # 1 USD = X target currency
tax_rate: 0.0 # e.g., 0.1385 for 13.85% ISS+PIS+COFINS
# ── Line items (only requested services) ────────────────────────
# Each item must reference a SKU from oci-sku-catalog.yaml.
# Only include services the customer actually needs.
line_items:
- sku: "B97384" # Must match oci-sku-catalog.yaml
qty: 4 # Number of instances/units
# hours_units: 730 # Override catalog default if needed
months: 12 # Contract duration
discount: 0.0 # 0.0 to 1.0 (e.g., 0.30 = 30%)
# custom_label: "" # Optional label
# custom_note: "" # Optional note
- sku: "B97385"
qty: 64 # 64 GB memory
months: 12
discount: 0.0
# ── Notes (optional) ────────────────────────────────────────────
notes:
- "Pricing based on Ashburn region (OC1)"
- "Discounts subject to Oracle approval"

730
tools/oci_bom_gen.py Normal file
View File

@@ -0,0 +1,730 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — Bill of Materials (BOM) Generator
Generates a professional .xlsx BOM from a YAML spec, resolving SKUs
against the OCI SKU catalog. Only requested services appear in the output.
Usage:
python oci_bom_gen.py --spec bom-spec.yaml --output customer-bom.xlsx
python oci_bom_gen.py --spec bom-spec.yaml --output bom.xlsx --catalog kb/pricing/oci-sku-catalog.yaml
"""
import argparse
import os
import sys
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import yaml
from openpyxl import Workbook
from openpyxl.styles import (
Alignment,
Border,
Font,
NamedStyle,
PatternFill,
Side,
numbers,
)
from openpyxl.utils import get_column_letter
# ── Oracle Redwood palette ────────────────────────────────────────
class Colors:
TEAL = "2C5967"
TEAL_LIGHT = "E8F0F2"
DARK_BG = "312D2A"
COPPER = "AA643B"
WHITE = "FFFFFF"
GRAY_100 = "F5F4F2"
GRAY_300 = "D4D1CC"
GRAY_500 = "6F6964"
GRAY_700 = "3A3631"
BLACK = "000000"
# ── Column definitions ────────────────────────────────────────────
# Base columns (always present)
BASE_COLS = [
("Part (SKU)", 14),
("Product / Description", 55),
("Metric", 32),
("Unit Price (USD)", 16),
("Hours/Units", 12),
("Qty", 10),
("Months", 10),
("Discount", 10),
("Monthly USD\n(w/o discount)", 18),
("Monthly USD\n(w/ discount)", 18),
]
# Conversion columns (added when conversion is enabled)
# Placeholders — header text is set dynamically with target currency
CONV_COLS = [
("Monthly {cur}\n(w/o tax)", 18),
("Monthly {cur}\n(w/ tax)", 18),
]
# Always-last column
TAIL_COLS = [
("Cost %", 10),
]
class OCIBomGenerator:
"""Generate OCI Bill of Materials (.xlsx) with Oracle Redwood styling."""
def __init__(
self,
customer: str = "",
project: str = "",
prepared_by: str = "",
bom_date: str = "",
reference_label: str = "",
currency: str = "USD",
realm: str = "",
service_type: str = "",
conversion: Optional[dict] = None,
notes: Optional[list] = None,
):
self.customer = customer
self.project = project
self.prepared_by = prepared_by
self.bom_date = bom_date or date.today().isoformat()
self.reference_label = reference_label
self.currency = currency
self.realm = realm
self.service_type = service_type
self.conversion = conversion or {"enabled": False}
self.notes = notes or []
self.catalog: dict = {} # sku_code -> {product, metric, ...}
self.catalog_meta: dict = {} # top-level catalog metadata
self.category_order: list = [] # ordered category keys
self.category_names: dict = {} # cat_key -> display_name
self.line_items: list = [] # resolved line items
self.wb = Workbook()
# ── Catalog loading ───────────────────────────────────────────
def load_catalog(self, catalog_path: Optional[str] = None):
"""Load SKU catalog from YAML. Auto-discovers path if not given."""
if not catalog_path:
# Auto-discover relative to this script
tools_dir = Path(__file__).resolve().parent
catalog_path = str(tools_dir.parent / "kb" / "pricing" / "oci-sku-catalog.yaml")
with open(catalog_path, "r") as f:
raw = yaml.safe_load(f)
self.catalog_meta = {
k: v for k, v in raw.items() if k != "categories"
}
categories = raw.get("categories", {})
for cat_key, cat_data in categories.items():
self.category_order.append(cat_key)
self.category_names[cat_key] = cat_data.get("display_name", cat_key)
for sku_entry in cat_data.get("skus", []):
sku_code = str(sku_entry["sku"])
self.catalog[sku_code] = {
**sku_entry,
"sku": sku_code,
"category": cat_key,
}
# ── Line items ────────────────────────────────────────────────
def add_line_item(
self,
sku: str,
qty: float = 0,
hours_units: Optional[float] = None,
months: int = 12,
discount: float = 0.0,
custom_label: str = "",
custom_note: str = "",
):
"""Add a line item, resolving SKU details from catalog."""
sku = str(sku)
cat_entry = self.catalog.get(sku, {})
item = {
"sku": sku,
"product": cat_entry.get("product", f"Unknown SKU ({sku})"),
"metric": cat_entry.get("metric", ""),
"list_price_usd": cat_entry.get("list_price_usd", 0),
"hours_units": hours_units if hours_units is not None else cat_entry.get("default_hours_units", 730),
"qty": qty,
"months": months,
"discount": discount,
"category": cat_entry.get("category", "other"),
"discountable": cat_entry.get("discountable", True),
"billing_type": cat_entry.get("billing_type", "hourly"),
"custom_label": custom_label,
"custom_note": custom_note,
}
self.line_items.append(item)
# ── Excel generation ──────────────────────────────────────────
def _col_defs(self) -> List[Tuple[str, int]]:
"""Return full column definitions based on conversion setting."""
cols = list(BASE_COLS)
if self.conversion.get("enabled"):
cur = self.conversion.get("target_currency", "BRL")
cols += [(h.format(cur=cur), w) for h, w in CONV_COLS]
cols += list(TAIL_COLS)
return cols
def _col_count(self) -> int:
return len(self._col_defs())
def _build_sheet(self, ws):
"""Build the complete BOM sheet."""
ws.title = "BOM"
col_defs = self._col_defs()
num_cols = len(col_defs)
conv = self.conversion.get("enabled", False)
# ── Styles ────────────────────────────────────────────────
thin_border = Border(
left=Side(style="thin", color=Colors.GRAY_300),
right=Side(style="thin", color=Colors.GRAY_300),
top=Side(style="thin", color=Colors.GRAY_300),
bottom=Side(style="thin", color=Colors.GRAY_300),
)
header_font = Font(name="Segoe UI", size=11, bold=True, color=Colors.WHITE)
header_fill = PatternFill(start_color=Colors.TEAL, end_color=Colors.TEAL, fill_type="solid")
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
cat_font = Font(name="Segoe UI", size=10, bold=True, color=Colors.TEAL)
cat_fill = PatternFill(start_color=Colors.TEAL_LIGHT, end_color=Colors.TEAL_LIGHT, fill_type="solid")
data_font = Font(name="Segoe UI", size=10, color=Colors.GRAY_700)
alt_fill = PatternFill(start_color=Colors.GRAY_100, end_color=Colors.GRAY_100, fill_type="solid")
total_font = Font(name="Segoe UI", size=11, bold=True, color=Colors.DARK_BG)
total_fill = PatternFill(start_color=Colors.GRAY_300, end_color=Colors.GRAY_300, fill_type="solid")
subtotal_font = Font(name="Segoe UI", size=10, bold=True, color=Colors.GRAY_700)
# ── Header area ───────────────────────────────────────────
row = 1
title_font = Font(name="Segoe UI", size=16, bold=True, color=Colors.TEAL)
ws.cell(row=row, column=1, value="Oracle Investment Proposal").font = title_font
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=num_cols)
row += 1
subtitle_font = Font(name="Segoe UI", size=10, color=Colors.GRAY_500)
ws.cell(row=row, column=1, value=f"as of {self.bom_date}").font = subtitle_font
row += 1
meta_font = Font(name="Segoe UI", size=10, color=Colors.GRAY_700)
meta_items = [
("Customer", self.customer),
("Project", self.project),
("Prepared by", self.prepared_by),
("Reference", self.reference_label),
("Currency", self.currency),
("Realm", self.realm),
("Service Type", self.service_type),
]
for label, value in meta_items:
if value:
ws.cell(row=row, column=1, value=f"{label}:").font = Font(
name="Segoe UI", size=10, bold=True, color=Colors.GRAY_700
)
ws.cell(row=row, column=2, value=value).font = meta_font
row += 1
if conv:
xr = self.conversion.get("exchange_rate", 1)
tc = self.conversion.get("target_currency", "BRL")
tr = self.conversion.get("tax_rate", 0)
ws.cell(row=row, column=1, value="Exchange Rate:").font = Font(
name="Segoe UI", size=10, bold=True, color=Colors.GRAY_700
)
ws.cell(row=row, column=2, value=f"1 USD = {xr} {tc}").font = meta_font
row += 1
if tr > 0:
ws.cell(row=row, column=1, value="Tax Rate:").font = Font(
name="Segoe UI", size=10, bold=True, color=Colors.GRAY_700
)
ws.cell(row=row, column=2, value=f"{tr*100:.2f}%").font = meta_font
row += 1
row += 1 # blank row before table
# ── Column headers ────────────────────────────────────────
header_row = row
for ci, (col_name, col_width) in enumerate(col_defs, 1):
cell = ws.cell(row=row, column=ci, value=col_name)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = thin_border
ws.column_dimensions[get_column_letter(ci)].width = col_width
row += 1
# ── Group items by category ───────────────────────────────
items_by_cat: dict[str, list] = {}
for item in self.line_items:
cat = item["category"]
items_by_cat.setdefault(cat, []).append(item)
# Column index references (1-based)
COL_SKU = 1
COL_PRODUCT = 2
COL_METRIC = 3
COL_PRICE = 4
COL_HOURS = 5
COL_QTY = 6
COL_MONTHS = 7
COL_DISC = 8
COL_M_WO = 9 # monthly without discount
COL_M_W = 10 # monthly with discount
if conv:
COL_CONV_WO = 11 # monthly converted without tax
COL_CONV_W = 12 # monthly converted with tax
COL_PCT = 13 # cost proportion
else:
COL_PCT = 11
data_start_row = row
cat_subtotal_rows = []
for cat_key in self.category_order:
if cat_key not in items_by_cat:
continue
cat_items = items_by_cat[cat_key]
display_name = self.category_names.get(cat_key, cat_key)
# Category header row
ws.cell(row=row, column=COL_PRODUCT, value=display_name).font = cat_font
for ci in range(1, num_cols + 1):
ws.cell(row=row, column=ci).fill = cat_fill
ws.cell(row=row, column=ci).border = thin_border
cat_header_row = row
row += 1
cat_first_data = row
for idx, item in enumerate(cat_items):
# Static data
ws.cell(row=row, column=COL_SKU, value=item["sku"])
ws.cell(row=row, column=COL_PRODUCT, value=item["product"])
ws.cell(row=row, column=COL_METRIC, value=item["metric"])
ws.cell(row=row, column=COL_PRICE, value=item["list_price_usd"])
ws.cell(row=row, column=COL_HOURS, value=item["hours_units"])
ws.cell(row=row, column=COL_QTY, value=item["qty"])
ws.cell(row=row, column=COL_MONTHS, value=item["months"])
ws.cell(row=row, column=COL_DISC, value=item["discount"])
# Formula: Monthly w/o discount = Price × Hours × Qty
p = get_column_letter(COL_PRICE)
h = get_column_letter(COL_HOURS)
q = get_column_letter(COL_QTY)
d = get_column_letter(COL_DISC)
ws.cell(
row=row, column=COL_M_WO,
value=f"={p}{row}*{h}{row}*{q}{row}",
)
# Formula: Monthly w/ discount = Monthly_wo × (1 - Discount)
mwo = get_column_letter(COL_M_WO)
ws.cell(
row=row, column=COL_M_W,
value=f"={mwo}{row}*(1-{d}{row})",
)
# Conversion formulas
if conv:
xr = self.conversion.get("exchange_rate", 1)
tr = self.conversion.get("tax_rate", 0)
mw = get_column_letter(COL_M_W)
# Converted w/o tax
ws.cell(
row=row, column=COL_CONV_WO,
value=f"={mw}{row}*{xr}",
)
# Converted w/ tax
ws.cell(
row=row, column=COL_CONV_W,
value=f"={get_column_letter(COL_CONV_WO)}{row}*(1+{tr})",
)
# Styling per row
is_alt = idx % 2 == 1
for ci in range(1, num_cols + 1):
cell = ws.cell(row=row, column=ci)
cell.font = data_font
cell.border = thin_border
if is_alt:
cell.fill = alt_fill
cell.alignment = Alignment(
vertical="center",
wrap_text=(ci == COL_PRODUCT),
)
# Number formats
ws.cell(row=row, column=COL_PRICE).number_format = '$#,##0.0000'
ws.cell(row=row, column=COL_HOURS).number_format = '#,##0'
ws.cell(row=row, column=COL_QTY).number_format = '#,##0.##'
ws.cell(row=row, column=COL_MONTHS).number_format = '#,##0'
ws.cell(row=row, column=COL_DISC).number_format = '0%'
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
if conv:
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
row += 1
# Category subtotal row
cat_last_data = row - 1
ws.cell(row=row, column=COL_PRODUCT, value=f"Subtotal — {display_name}")
mwo_letter = get_column_letter(COL_M_WO)
mw_letter = get_column_letter(COL_M_W)
ws.cell(row=row, column=COL_M_WO, value=f"=SUM({mwo_letter}{cat_first_data}:{mwo_letter}{cat_last_data})")
ws.cell(row=row, column=COL_M_W, value=f"=SUM({mw_letter}{cat_first_data}:{mw_letter}{cat_last_data})")
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
if conv:
cwo_letter = get_column_letter(COL_CONV_WO)
cw_letter = get_column_letter(COL_CONV_W)
ws.cell(row=row, column=COL_CONV_WO, value=f"=SUM({cwo_letter}{cat_first_data}:{cwo_letter}{cat_last_data})")
ws.cell(row=row, column=COL_CONV_W, value=f"=SUM({cw_letter}{cat_first_data}:{cw_letter}{cat_last_data})")
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
for ci in range(1, num_cols + 1):
cell = ws.cell(row=row, column=ci)
cell.font = subtotal_font
cell.border = thin_border
cell.fill = PatternFill(start_color="EDE9E5", end_color="EDE9E5", fill_type="solid")
cat_subtotal_rows.append(row)
row += 1 # blank row between categories
# ── Grand total ───────────────────────────────────────────
total_row = row
ws.cell(row=row, column=COL_PRODUCT, value="TOTAL").font = total_font
# Sum of subtotals
mwo_refs = "+".join(f"{get_column_letter(COL_M_WO)}{r}" for r in cat_subtotal_rows)
mw_refs = "+".join(f"{get_column_letter(COL_M_W)}{r}" for r in cat_subtotal_rows)
ws.cell(row=row, column=COL_M_WO, value=f"={mwo_refs}")
ws.cell(row=row, column=COL_M_W, value=f"={mw_refs}")
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
if conv:
cwo_refs = "+".join(f"{get_column_letter(COL_CONV_WO)}{r}" for r in cat_subtotal_rows)
cw_refs = "+".join(f"{get_column_letter(COL_CONV_W)}{r}" for r in cat_subtotal_rows)
ws.cell(row=row, column=COL_CONV_WO, value=f"={cwo_refs}")
ws.cell(row=row, column=COL_CONV_W, value=f"={cw_refs}")
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
for ci in range(1, num_cols + 1):
cell = ws.cell(row=row, column=ci)
cell.font = total_font
cell.fill = total_fill
cell.border = thin_border
row += 1
# ── ARR (Annual Run Rate) ────────────────────────────────
arr_row = row
ws.cell(row=row, column=COL_PRODUCT, value="Annual Run Rate (ARR)").font = Font(
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
)
mw_total = get_column_letter(COL_M_W)
ws.cell(row=row, column=COL_M_W, value=f"={mw_total}{total_row}*12")
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
ws.cell(row=row, column=COL_M_W).font = Font(
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
)
if conv:
cw_total = get_column_letter(COL_CONV_W)
ws.cell(row=row, column=COL_CONV_W, value=f"={cw_total}{total_row}*12")
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
ws.cell(row=row, column=COL_CONV_W).font = Font(
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
)
for ci in range(1, num_cols + 1):
ws.cell(row=row, column=ci).border = thin_border
row += 2
# ── Cost proportion formulas (back-fill) ─────────────────
# Cost % = line monthly w/ discount / total monthly w/ discount
for item_row in range(data_start_row, total_row):
cell_val = ws.cell(row=item_row, column=COL_M_W).value
if cell_val and str(cell_val).startswith("=") and "SUM" not in str(cell_val):
mw_l = get_column_letter(COL_M_W)
ws.cell(
row=item_row, column=COL_PCT,
value=f"=IF({mw_l}{total_row}=0,0,{mw_l}{item_row}/{mw_l}{total_row})",
)
ws.cell(row=item_row, column=COL_PCT).number_format = '0.0%'
ws.cell(row=item_row, column=COL_PCT).font = data_font
ws.cell(row=item_row, column=COL_PCT).border = thin_border
# ── Notes ─────────────────────────────────────────────────
if self.notes:
ws.cell(row=row, column=1, value="Notes:").font = Font(
name="Segoe UI", size=10, bold=True, color=Colors.GRAY_500
)
row += 1
for note in self.notes:
ws.cell(row=row, column=1, value=f" - {note}").font = Font(
name="Segoe UI", size=9, color=Colors.GRAY_500
)
row += 1
row += 1
# ── Disclaimer ────────────────────────────────────────────
disclaimer = self.catalog_meta.get("disclaimer", "")
if disclaimer:
ws.cell(row=row, column=1, value="Disclaimer:").font = Font(
name="Segoe UI", size=8, bold=True, color=Colors.GRAY_500
)
row += 1
ws.merge_cells(
start_row=row, start_column=1,
end_row=row, end_column=num_cols,
)
cell = ws.cell(row=row, column=1, value=disclaimer.strip())
cell.font = Font(name="Segoe UI", size=8, color=Colors.GRAY_500)
cell.alignment = Alignment(wrap_text=True, vertical="top")
ws.row_dimensions[row].height = 60
# ── Print setup ───────────────────────────────────────────
ws.sheet_properties.pageSetUpPr = None
ws.page_setup.orientation = "landscape"
ws.page_setup.fitToWidth = 1
ws.page_setup.fitToHeight = 0
ws.print_title_rows = f"{header_row}:{header_row}"
# ── AppCA Export ──────────────────────────────────────────────
def _build_appca_sheets(self):
"""Build the two sheets needed for AppCA import: 'Export to AppCA' and 'BOM.C1'."""
# ── Styles ────────────────────────────────────────────────
thin_border = Border(
left=Side(style="thin", color=Colors.GRAY_300),
right=Side(style="thin", color=Colors.GRAY_300),
top=Side(style="thin", color=Colors.GRAY_300),
bottom=Side(style="thin", color=Colors.GRAY_300),
)
header_font = Font(name="Segoe UI", size=11, bold=True, color=Colors.WHITE)
header_fill = PatternFill(start_color=Colors.TEAL, end_color=Colors.TEAL, fill_type="solid")
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
data_font = Font(name="Segoe UI", size=10, color=Colors.GRAY_700)
cat_font = Font(name="Segoe UI", size=10, bold=True, color=Colors.TEAL)
cat_fill = PatternFill(start_color=Colors.TEAL_LIGHT, end_color=Colors.TEAL_LIGHT, fill_type="solid")
# ── Sheet: BOM.C1 ─────────────────────────────────────────
ws_bom = self.wb.create_sheet("BOM.C1")
# Row 1: global parameters
ws_bom.cell(row=1, column=6, value="Hours: ").font = Font(name="Segoe UI", size=10, bold=True)
ws_bom.cell(row=1, column=7, value=730)
ws_bom.cell(row=1, column=8, value="Months: ").font = Font(name="Segoe UI", size=10, bold=True)
months_val = self.line_items[0]["months"] if self.line_items else 12
ws_bom.cell(row=1, column=9, value=months_val)
# Row 4: title
ws_bom.cell(row=4, column=4, value="Oracle Cloud Infrastructure - Bill of Material (BOM)").font = Font(
name="Segoe UI", size=12, bold=True, color=Colors.TEAL
)
# Row 5: column headers (matching original BOM.C1 layout: C=SKU, D=Product, etc.)
bom_headers = [
(3, "SKU", 14), (4, "Product", 50), (5, "Metric", 30),
(6, "Price List US$", 14), (7, "Hours/Units", 12),
(8, "Qty", 10), (9, "Months", 10), (10, "Discount", 10),
(11, "US$\n(w/o discount)", 16), (12, "US$\n(w/ discount)", 16),
]
for col, name, width in bom_headers:
cell = ws_bom.cell(row=5, column=col, value=name)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = thin_border
ws_bom.column_dimensions[get_column_letter(col)].width = width
# Row 6+: data rows grouped by category
row = 6
items_by_cat = {}
for item in self.line_items:
cat = item["category"]
items_by_cat.setdefault(cat, []).append(item)
for cat_key in self.category_order:
if cat_key not in items_by_cat:
continue
display_name = self.category_names.get(cat_key, cat_key)
# Category header
ws_bom.cell(row=row, column=4, value=display_name).font = cat_font
for ci in range(3, 13):
ws_bom.cell(row=row, column=ci).fill = cat_fill
ws_bom.cell(row=row, column=ci).border = thin_border
row += 1
for item in items_by_cat[cat_key]:
ws_bom.cell(row=row, column=3, value=item["sku"])
ws_bom.cell(row=row, column=4, value=item["product"])
ws_bom.cell(row=row, column=5, value=item["metric"])
ws_bom.cell(row=row, column=6, value=item["list_price_usd"])
ws_bom.cell(row=row, column=7, value=item["hours_units"])
ws_bom.cell(row=row, column=8, value=item["qty"])
ws_bom.cell(row=row, column=9, value=item["months"])
ws_bom.cell(row=row, column=10, value=item["discount"])
# Formulas matching BOM.C1 style
ws_bom.cell(row=row, column=11,
value="=(F{r}*G{r}*H{r})".format(r=row))
ws_bom.cell(row=row, column=12,
value="=IFERROR(K{r}*(1-J{r}),K{r})".format(r=row))
# Formats
ws_bom.cell(row=row, column=6).number_format = '#,##0.0000'
ws_bom.cell(row=row, column=10).number_format = '0.0%'
ws_bom.cell(row=row, column=11).number_format = '$#,##0.00'
ws_bom.cell(row=row, column=12).number_format = '$#,##0.00'
for ci in range(3, 13):
cell = ws_bom.cell(row=row, column=ci)
cell.font = data_font
cell.border = thin_border
row += 1
# ── Sheet: Export to AppCA ─────────────────────────────────
ws_appca = self.wb.create_sheet("Export to AppCA", 0) # first sheet
# Title
ws_appca.cell(row=1, column=1, value="TEMPLATE BOM - APPCA").font = Font(
name="Segoe UI", size=14, bold=True, color=Colors.TEAL
)
ws_appca.merge_cells("A1:G1")
# Headers — AppCA expects: SKU, QTY, HOURS, MONTHS, DISCOUNT, BURSTABLE, STATUS
appca_headers = ["SKU", "QTY", "HOURS", "MONTHS", "DISCOUNT", "BURSTABLE", "STATUS"]
appca_widths = [14, 10, 12, 10, 12, 10, 12]
for ci, (name, width) in enumerate(zip(appca_headers, appca_widths), 1):
cell = ws_appca.cell(row=2, column=ci, value=name)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = thin_border
ws_appca.column_dimensions[get_column_letter(ci)].width = width
# Data rows: only items with qty > 0
appca_row = 3
for item in self.line_items:
if item["qty"] > 0:
ws_appca.cell(row=appca_row, column=1, value=item["sku"])
ws_appca.cell(row=appca_row, column=2, value=item["qty"])
ws_appca.cell(row=appca_row, column=3, value=item["hours_units"])
ws_appca.cell(row=appca_row, column=4, value=item["months"])
ws_appca.cell(row=appca_row, column=5, value=item["discount"])
ws_appca.cell(row=appca_row, column=6, value="No") # BURSTABLE: No unless specified
ws_appca.cell(row=appca_row, column=7, value="Yes") # STATUS: required by AppCA
ws_appca.cell(row=appca_row, column=5).number_format = '0.0%'
for ci in range(1, 8):
cell = ws_appca.cell(row=appca_row, column=ci)
cell.font = data_font
cell.border = thin_border
appca_row += 1
# ── Public API ────────────────────────────────────────────────
def save(self, filepath: str, appca: bool = False):
"""Build and save the workbook to an .xlsx file."""
if appca:
# Remove default empty sheet, build AppCA sheets
default_sheet = self.wb.active
self._build_appca_sheets()
self.wb.remove(default_sheet)
else:
ws = self.wb.active
self._build_sheet(ws)
self.wb.save(filepath)
print(" BOM saved: {}".format(filepath))
@classmethod
def from_spec(cls, spec: dict, catalog_path: Optional[str] = None) -> "OCIBomGenerator":
"""Build BOM from a YAML specification dict."""
bom = spec.get("bom", spec)
gen = cls(
customer=bom.get("customer_name", ""),
project=bom.get("project_name", ""),
prepared_by=bom.get("prepared_by", ""),
bom_date=bom.get("date", ""),
reference_label=bom.get("reference_label", ""),
currency=bom.get("currency", "USD"),
realm=bom.get("realm", ""),
service_type=bom.get("service_type", ""),
conversion=bom.get("conversion"),
notes=bom.get("notes"),
)
gen.load_catalog(catalog_path)
for item in bom.get("line_items", []):
gen.add_line_item(
sku=str(item["sku"]),
qty=item.get("qty", 0),
hours_units=item.get("hours_units"),
months=item.get("months", 12),
discount=item.get("discount", 0.0),
custom_label=item.get("custom_label", ""),
custom_note=item.get("custom_note", ""),
)
return gen
def main():
parser = argparse.ArgumentParser(
description="OCI Deal Accelerator — BOM Generator (.xlsx)"
)
parser.add_argument(
"--spec", required=True,
help="Path to BOM spec YAML file",
)
parser.add_argument(
"--output", default="bom.xlsx",
help="Output .xlsx file path (default: bom.xlsx)",
)
parser.add_argument(
"--catalog", default=None,
help="Override path to SKU catalog YAML (default: auto-discover)",
)
parser.add_argument(
"--appca", action="store_true",
help="Generate AppCA import format (2 sheets: 'Export to AppCA' + 'BOM.C1')",
)
args = parser.parse_args()
with open(args.spec, "r") as f:
spec = yaml.safe_load(f)
gen = OCIBomGenerator.from_spec(spec, catalog_path=args.catalog)
gen.save(args.output, appca=args.appca)
if __name__ == "__main__":
main()

View File

@@ -27,6 +27,7 @@ FORMATS = {
"full": ["pptx", "drawio", "docx", "xlsx", "pdf"], "full": ["pptx", "drawio", "docx", "xlsx", "pdf"],
"doc": ["docx"], "doc": ["docx"],
"pdf": ["pdf"], "pdf": ["pdf"],
"bom": ["bom"],
} }
GENERATORS = { GENERATORS = {
@@ -62,6 +63,13 @@ GENERATORS = {
"method": "from_spec", "method": "from_spec",
"description": "Customer-facing PDF (.pdf)", "description": "Customer-facing PDF (.pdf)",
}, },
"bom": {
"module": "oci_bom_gen",
"class": "OCIBomGenerator",
"method": "from_spec",
"description": "Bill of Materials (.xlsx)",
"optional": True,
},
} }

View File

@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""
OCI Deal Accelerator — SKU Catalog Refresh Tool
Fetches current OCI SKU pricing from Oracle's public pricing API and
updates kb/pricing/oci-sku-catalog.yaml with the latest prices.
API endpoint (anonymous, no auth required):
https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products
Usage:
# Refresh full catalog from API
python tools/refresh_sku_catalog.py --refresh
# Refresh and show what changed
python tools/refresh_sku_catalog.py --refresh --diff
# Fetch a specific SKU to inspect
python tools/refresh_sku_catalog.py --sku B110627
# Export raw API dump to JSON (for debugging)
python tools/refresh_sku_catalog.py --dump oci-skus-raw.json
# Validate current catalog against API (report stale prices)
python tools/refresh_sku_catalog.py --validate
"""
import argparse
import json
import os
import sys
import time
from datetime import date
from pathlib import Path
try:
import requests
except ImportError:
print("Error: 'requests' package required. Install with: pip install requests")
sys.exit(1)
import yaml
# ── API Configuration ─────────────────────────────────────────────
# Public Oracle pricing API (no authentication required)
# Documented: https://docs.oracle.com/en-us/iaas/Content/GSG/Tasks/signingup_topic-Estimating_Costs.htm
API_BASE = "https://apexapps.oracle.com/pls/apex/cetools/api/v1/products/"
DEFAULT_CURRENCY = "USD"
# ── Paths ─────────────────────────────────────────────────────────
TOOLS_DIR = Path(__file__).resolve().parent
CATALOG_PATH = TOOLS_DIR.parent / "kb" / "pricing" / "oci-sku-catalog.yaml"
def fetch_all_products(currency="USD", verbose=False):
"""Fetch all OCI products from the APEX pricing API."""
url = "{}?currencyCode={}".format(API_BASE, currency)
if verbose:
print(" Fetching: {}".format(url))
resp = requests.get(url, timeout=60)
resp.raise_for_status()
data = resp.json()
items = data.get("items", [])
if verbose:
print(" Fetched {} products".format(len(items)))
last_updated = data.get("lastUpdated", "unknown")
print(" API last updated: {}".format(last_updated))
return items
def fetch_single_sku(part_number, currency="USD"):
"""Fetch a single SKU by part number."""
url = "{}?partNumber={}&currencyCode={}".format(API_BASE, part_number, currency)
resp = requests.get(url, timeout=15)
resp.raise_for_status()
data = resp.json()
return data.get("items", [])
def extract_payg_price(product, currency="USD"):
"""Extract the Pay As You Go price from a product's prices structure.
APEX API format:
currencyCodeLocalizations: [
{ currencyCode: "USD", prices: [ {model: "PAY_AS_YOU_GO", value: 2.9032} ] }
]
"""
localizations = product.get("currencyCodeLocalizations", [])
for loc in localizations:
if loc.get("currencyCode") == currency:
for price in loc.get("prices", []):
if price.get("model") == "PAY_AS_YOU_GO":
return price.get("value", 0)
# Fallback: take first price in this currency
prices = loc.get("prices", [])
if prices:
return prices[0].get("value", 0)
# Legacy format fallback (itra API)
for price in product.get("prices", []):
if price.get("model") == "PAY_AS_YOU_GO":
return price.get("value", 0)
return 0
def load_current_catalog():
"""Load the current YAML catalog and build a SKU lookup."""
if not CATALOG_PATH.exists():
return {}, {}
with open(str(CATALOG_PATH), "r") as f:
raw = yaml.safe_load(f)
sku_map = {} # sku -> {product, category, ...}
categories = raw.get("categories", {})
for cat_key, cat_data in categories.items():
for entry in cat_data.get("skus", []):
sku_map[str(entry["sku"])] = {
"category": cat_key,
"product": entry.get("product", ""),
"list_price_usd": entry.get("list_price_usd", 0),
}
return raw, sku_map
def refresh_catalog(verbose=False):
"""Refresh the catalog YAML with latest API prices."""
print("Fetching current prices from Oracle API...")
products = fetch_all_products(currency=DEFAULT_CURRENCY, verbose=verbose)
# Build API lookup: partNumber -> product data
api_map = {}
for p in products:
pn = p.get("partNumber", "")
if pn:
api_map[pn] = p
print(" {} products from API".format(len(api_map)))
# Load current catalog
raw, sku_map = load_current_catalog()
if not raw:
print("Error: Could not load catalog at {}".format(CATALOG_PATH))
return
updated = 0
not_found = 0
unchanged = 0
categories = raw.get("categories", {})
for cat_key, cat_data in categories.items():
for entry in cat_data.get("skus", []):
sku = str(entry["sku"])
if sku in api_map:
api_price = extract_payg_price(api_map[sku])
old_price = entry.get("list_price_usd", 0)
# Skip $0 API prices when catalog has a real price
# (API returns $0 for tiered/free-tier SKUs where
# the base tier is free but usage above free tier costs)
if api_price == 0 and old_price > 0:
if verbose:
print(" {} SKIP: API=$0 but catalog=${} (tiered/free-tier SKU)".format(
sku, old_price
))
unchanged += 1
elif abs(api_price - old_price) > 0.0001:
if verbose:
print(" {} price: {} -> {} ({})".format(
sku, old_price, api_price, entry.get("product", "")[:50]
))
entry["list_price_usd"] = api_price
updated += 1
else:
unchanged += 1
# Also update product name if different
api_name = api_map[sku].get("displayName", "")
if api_name and api_name != entry.get("product", ""):
# Keep our shorter names, but log the difference
pass
# Update metric from API
api_metric = api_map[sku].get("metricDisplayName", "")
if api_metric and api_metric != entry.get("metric", ""):
pass # Keep our curated metrics
else:
not_found += 1
if verbose:
print(" {} NOT in API (may be retired): {}".format(
sku, entry.get("product", "")[:50]
))
# Update last_verified date
raw["last_verified"] = date.today().isoformat()
# Write back
with open(str(CATALOG_PATH), "w") as f:
yaml.dump(raw, f, default_flow_style=False, allow_unicode=True, sort_keys=False, width=120)
print("\nRefresh complete:")
print(" Updated: {}".format(updated))
print(" Unchanged: {}".format(unchanged))
print(" Not in API (possibly retired): {}".format(not_found))
print(" Catalog saved: {}".format(CATALOG_PATH))
def validate_catalog(verbose=False):
"""Compare current catalog prices against API and report differences."""
print("Validating catalog against Oracle API...")
products = fetch_all_products(currency=DEFAULT_CURRENCY, verbose=verbose)
api_map = {}
for p in products:
pn = p.get("partNumber", "")
if pn:
api_map[pn] = p
raw, sku_map = load_current_catalog()
if not raw:
print("Error: Could not load catalog")
return
diffs = []
missing = []
categories = raw.get("categories", {})
for cat_key, cat_data in categories.items():
for entry in cat_data.get("skus", []):
sku = str(entry["sku"])
if sku in api_map:
api_price = extract_payg_price(api_map[sku])
cat_price = entry.get("list_price_usd", 0)
# Skip $0 API prices for tiered/free-tier SKUs
if api_price == 0 and cat_price > 0:
continue
if abs(api_price - cat_price) > 0.0001:
diffs.append({
"sku": sku,
"product": entry.get("product", "")[:60],
"catalog_price": cat_price,
"api_price": api_price,
"diff_pct": ((api_price - cat_price) / cat_price * 100) if cat_price else 0,
})
else:
missing.append(sku)
if diffs:
print("\nPrice differences found ({}):\n".format(len(diffs)))
print("{:<10} {:<60} {:>12} {:>12} {:>8}".format(
"SKU", "Product", "Catalog", "API", "Diff %"
))
print("-" * 102)
for d in diffs:
print("{:<10} {:<60} ${:>10.4f} ${:>10.4f} {:>7.1f}%".format(
d["sku"], d["product"], d["catalog_price"], d["api_price"], d["diff_pct"]
))
print("\nRun with --refresh to update the catalog.")
else:
print("\nAll prices match the API. Catalog is up to date.")
if missing:
print("\n{} SKUs not found in API (possibly retired):".format(len(missing)))
for s in missing[:10]:
print(" - {}".format(s))
if len(missing) > 10:
print(" ... and {} more".format(len(missing) - 10))
def inspect_sku(part_number):
"""Fetch and display a single SKU from the API."""
print("Fetching SKU {} from API...".format(part_number))
items = fetch_single_sku(part_number)
if not items:
print(" SKU not found in API")
return
for item in items:
print("\n Part Number: {}".format(item.get("partNumber", "")))
print(" Display Name: {}".format(item.get("displayName", "")))
print(" Category: {}".format(item.get("serviceCategory", "N/A")))
print(" Metric: {}".format(item.get("metricName", "N/A")))
print(" Prices:")
for loc in item.get("currencyCodeLocalizations", []):
cc = loc.get("currencyCode", "?")
for price in loc.get("prices", []):
print(" - {} {} : {}".format(
cc, price.get("model", "?"), price.get("value", "?")
))
def dump_all(output_path, verbose=False):
"""Dump raw API response to JSON file."""
products = fetch_all_products(currency=DEFAULT_CURRENCY, verbose=verbose)
with open(output_path, "w") as f:
json.dump(products, f, indent=2)
print("Dumped {} products to {}".format(len(products), output_path))
def main():
parser = argparse.ArgumentParser(
description="OCI Deal Accelerator — SKU Catalog Refresh Tool"
)
parser.add_argument("--refresh", action="store_true",
help="Refresh catalog prices from Oracle API")
parser.add_argument("--validate", action="store_true",
help="Validate catalog prices against API")
parser.add_argument("--sku", type=str,
help="Inspect a single SKU from the API")
parser.add_argument("--dump", type=str, metavar="FILE",
help="Dump raw API data to JSON file")
parser.add_argument("--diff", action="store_true",
help="Show detailed price changes during refresh")
parser.add_argument("-v", "--verbose", action="store_true",
help="Verbose output")
args = parser.parse_args()
if args.sku:
inspect_sku(args.sku)
elif args.refresh:
refresh_catalog(verbose=args.verbose or args.diff)
elif args.validate:
validate_catalog(verbose=args.verbose)
elif args.dump:
dump_all(args.dump, verbose=args.verbose)
else:
parser.print_help()
if __name__ == "__main__":
main()