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
This commit is contained in:
730
tools/oci_bom_gen.py
Normal file
730
tools/oci_bom_gen.py
Normal 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()
|
||||
@@ -27,6 +27,7 @@ FORMATS = {
|
||||
"full": ["pptx", "drawio", "docx", "xlsx", "pdf"],
|
||||
"doc": ["docx"],
|
||||
"pdf": ["pdf"],
|
||||
"bom": ["bom"],
|
||||
}
|
||||
|
||||
GENERATORS = {
|
||||
@@ -62,6 +63,13 @@ GENERATORS = {
|
||||
"method": "from_spec",
|
||||
"description": "Customer-facing PDF (.pdf)",
|
||||
},
|
||||
"bom": {
|
||||
"module": "oci_bom_gen",
|
||||
"class": "OCIBomGenerator",
|
||||
"method": "from_spec",
|
||||
"description": "Bill of Materials (.xlsx)",
|
||||
"optional": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
335
tools/refresh_sku_catalog.py
Normal file
335
tools/refresh_sku_catalog.py
Normal 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={}¤cyCode={}".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()
|
||||
Reference in New Issue
Block a user